From 387044caacc7ac665de78fbf442adf3c6144c5da Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 15 Mar 2026 16:31:29 +0100 Subject: [PATCH 01/42] wip, but it works so far --- eslint.config.ts | 1 + package-lock.json | 642 +++++++++++++++++- package.json | 1 + src/device/deviceManager.ts | 5 - src/device/protocol/airotic/airoticDevice.ts | 10 + .../protocol/airotic/airoticDeviceProvider.ts | 88 +++ .../protocol/airotic/airtonicProtocol.ts | 26 + src/device/protocol/messageResponseHandler.ts | 4 +- src/device/provider/deviceProviderLoader.ts | 14 +- src/device/provider/serialDeviceProvider.ts | 3 +- src/device/transport/bleDeviceTransport.ts | 78 +++ src/device/transport/bleObserver.ts | 67 ++ src/device/transport/serialPortObserver.ts | 7 +- src/index.ts | 2 + src/serviceMap.ts | 3 + src/serviceProvider/deviceServiceProvider.ts | 36 +- 16 files changed, 932 insertions(+), 55 deletions(-) create mode 100644 src/device/protocol/airotic/airoticDevice.ts create mode 100644 src/device/protocol/airotic/airoticDeviceProvider.ts create mode 100644 src/device/protocol/airotic/airtonicProtocol.ts create mode 100644 src/device/transport/bleDeviceTransport.ts create mode 100644 src/device/transport/bleObserver.ts diff --git a/eslint.config.ts b/eslint.config.ts index 99822f31..d4019e44 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -83,6 +83,7 @@ export default [ "@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-floating-promises": "warn", + "@typescript-eslint/unbound-method": "error", "@typescript-eslint/no-misused-promises": "error", "@typescript-eslint/no-misused-new": "error", "@typescript-eslint/no-namespace": "error", diff --git a/package-lock.json b/package-lock.json index adf1e475..0a0ebebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "license": "LGPL-3.0-only", "dependencies": { "@sinclair/typebox": "^0.34.47", + "@stoprocent/noble": "^2.3.17", "@timesplinter/pimple": "^2.0.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", @@ -3116,6 +3117,367 @@ "dev": true, "license": "MIT" }, + "node_modules/@stoprocent/bluetooth-hci-socket": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@stoprocent/bluetooth-hci-socket/-/bluetooth-hci-socket-2.2.6.tgz", + "integrity": "sha512-elKIsQe78EnxXzxfA/iuqGufD02d6t3S95eNhFaPvTJs7S3IHq2CdIHwTg3BLwae7Jw14mwK0VhWVtNtzrLyoA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux", + "android", + "freebsd", + "win32", + "darwin" + ], + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.7", + "node-addon-api": "^8.3.1", + "node-gyp-build": "^4.8.4", + "patch-package": "^8.0.0", + "serialport": "^12.0.0" + }, + "optionalDependencies": { + "usb": "^2.14.0" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/bindings-cpp": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-12.0.1.tgz", + "integrity": "sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "11.0.0", + "debug": "4.3.4", + "node-addon-api": "7.0.0", + "node-gyp-build": "4.6.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-11.0.0.tgz", + "integrity": "sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-11.0.0.tgz", + "integrity": "sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@serialport/parser-delimiter": "11.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/bindings-cpp/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/bindings-cpp/node_modules/node-addon-api": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz", + "integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/bindings-cpp/node_modules/node-gyp-build": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", + "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-byte-length": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-12.0.0.tgz", + "integrity": "sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-cctalk": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-12.0.0.tgz", + "integrity": "sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-inter-byte-timeout": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-12.0.0.tgz", + "integrity": "sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-packet-length": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-12.0.0.tgz", + "integrity": "sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-ready": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-12.0.0.tgz", + "integrity": "sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-regex": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-12.0.0.tgz", + "integrity": "sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-slip-encoder": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-12.0.0.tgz", + "integrity": "sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/parser-spacepacket": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-12.0.0.tgz", + "integrity": "sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/stream": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-12.0.0.tgz", + "integrity": "sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.3.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/@serialport/stream/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT", + "optional": true + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/serialport": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-12.0.0.tgz", + "integrity": "sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "12.0.1", + "@serialport/parser-byte-length": "12.0.0", + "@serialport/parser-cctalk": "12.0.0", + "@serialport/parser-delimiter": "12.0.0", + "@serialport/parser-inter-byte-timeout": "12.0.0", + "@serialport/parser-packet-length": "12.0.0", + "@serialport/parser-readline": "12.0.0", + "@serialport/parser-ready": "12.0.0", + "@serialport/parser-regex": "12.0.0", + "@serialport/parser-slip-encoder": "12.0.0", + "@serialport/parser-spacepacket": "12.0.0", + "@serialport/stream": "12.0.0", + "debug": "4.3.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/serialport/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@stoprocent/noble": { + "version": "2.3.17", + "resolved": "https://registry.npmjs.org/@stoprocent/noble/-/noble-2.3.17.tgz", + "integrity": "sha512-m1BP+B+8sYCzsnJDBeoZYQGDdGanXXXIPx3hVKBbR4tWcJFQ0RK8GM1PcavEuYW4+xkmf75MvodM/b9vT25F7A==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7", + "node-addon-api": "^8.1.0", + "node-gyp-build": "^4.8.1", + "patch-package": "^8.0.0" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@stoprocent/bluetooth-hci-socket": "^2.2.6" + } + }, "node_modules/@timesplinter/pimple": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@timesplinter/pimple/-/pimple-2.0.0.tgz", @@ -3292,6 +3654,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.13.tgz", + "integrity": "sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3713,6 +4082,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "license": "BSD-2-Clause" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -3810,7 +4185,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3950,6 +4324,13 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT", + "optional": true + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4125,7 +4506,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4253,7 +4633,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4342,7 +4721,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -4393,6 +4771,21 @@ "node": ">= 6" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", @@ -4418,7 +4811,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4431,7 +4823,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -4565,7 +4956,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4668,7 +5058,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -5548,7 +5937,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -5622,6 +6010,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -5677,6 +6074,20 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5909,6 +6320,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5926,7 +6343,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5936,7 +6352,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -6335,6 +6750,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-error-instance": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-error-instance/-/is-error-instance-3.0.1.tgz", @@ -6433,7 +6863,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -6632,18 +7061,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -6800,6 +7239,25 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -6820,6 +7278,27 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6840,6 +7319,15 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6985,6 +7473,19 @@ "node": ">=18.18.0" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -7027,7 +7528,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7280,7 +7780,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7388,6 +7887,22 @@ "integrity": "sha512-qAMrwuk2xLEutlASoiPiAMW3EN3K96Ka/ilSXYr6qR1zSVXw2j7+yDSqGTC4T9apfLYxM3tLLjKvgPdAUK7kYQ==", "license": "MIT" }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7515,6 +8030,47 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7529,7 +8085,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7570,7 +8125,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -8481,7 +9035,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -8549,7 +9102,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8562,7 +9114,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8673,6 +9224,15 @@ "node": ">=10" } }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/socket.io": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", @@ -8990,7 +9550,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -9120,11 +9679,19 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -9434,6 +10001,15 @@ "node": ">=8" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -9484,6 +10060,22 @@ "punycode": "^2.1.0" } }, + "node_modules/usb": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/usb/-/usb-2.17.0.tgz", + "integrity": "sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/w3c-web-usb": "^1.0.6", + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.5.0" + }, + "engines": { + "node": ">=12.22.0 <13.0 || >=14.17.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -9746,7 +10338,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9925,10 +10516,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, "license": "ISC", - "optional": true, - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index e7e83b73..58da2215 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@sinclair/typebox": "^0.34.47", + "@stoprocent/noble": "^2.3.17", "@timesplinter/pimple": "^2.0.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index e2a9f96a..134d57f0 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -1,6 +1,5 @@ import Device, { DeviceAttributes, DeviceEvent } from './device.js'; import EventEmitter from 'events'; -import { PortInfo } from '@serialport/bindings-interface'; import DeviceState from './deviceState.js'; import { setIntervalAsync } from '../util/async.js'; import Logger from '../logging/Logger.js'; @@ -11,10 +10,6 @@ export type DeviceInfo = { id: string; }; -export type SerialDeviceInfo = DeviceInfo & { - portInfo: PortInfo; -}; - export enum DeviceManagerEvent { deviceConnected = 'deviceConnected', deviceDisconnected = 'deviceDisconnected', diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts new file mode 100644 index 00000000..20348e52 --- /dev/null +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -0,0 +1,10 @@ +import { Int, Float } from '../../../util/numbers.js'; +import Device from '../../device.js'; + +export default class AiroticDevice extends Device +{ + public override setAttribute(attributeName: K, value: V): Promise { + console.log(attributeName, value); + throw new Error('Method not implemented.'); + } +} diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts new file mode 100644 index 00000000..a099a87d --- /dev/null +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -0,0 +1,88 @@ +import EventEmitter from 'events'; +import BaseError from 'modern-errors'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../../deviceManager.js'; +import DeviceProvider from '../../provider/deviceProvider.js'; +import AiroticDevice from './airoticDevice.js'; +import Logger from '../../../logging/Logger.js'; +import { asyncHandler } from '../../../util/async.js'; +import { logError } from '../../../util/error.js'; +import { BleDeviceInfo } from '../../transport/bleObserver.js'; +import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; +import AiroticProtocol from './airtonicProtocol.js'; +import MessageResponseHandler from '../messageResponseHandler.js'; + +export default class AiroticDeviceProvider extends DeviceProvider +{ + public static readonly providerName = 'airotic'; + + private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; + private static readonly UART_RX_CHAR_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; + private static readonly UART_TX_CHAR_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; + + public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + super(deviceManager, eventEmitter, logger.child({ name: AiroticDeviceProvider.name })); + + this.deviceManager.on( + DeviceManagerEvent.deviceDetected, + asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) + ) + ); + } + + public override async init(): Promise { + // no initialization needed, Airotic devices are announced by the AiroticBleObserver + this.logger.debug('Initialized AiroticDeviceProvider'); + } + + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + if (!this.isBleDeviceInfo(deviceInfo)) { + return; + } + + try { + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + + await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + + this.logger.debug(`Connected to device: ${deviceInfo.id}`); + + const transport = await BleUartDeviceTransport.create( + deviceInfo.peripheral, + AiroticDeviceProvider.UART_RX_CHAR_UUID, + AiroticDeviceProvider.UART_TX_CHAR_UUID + ); + + const protocol = new AiroticProtocol(); + + const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger); + + const handshakeResult = await this.doHandshake(messageResponseHandler); + + console.log(`Handshake result: ${handshakeResult}, create device and add to device manager!`); + + this.deviceManager.claimDetectedDevice(deviceInfo.id); + } catch (e: unknown) { + logError(this.logger, `Error while connecting to device`, e); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + } + } + + private async doHandshake(messageResponseHandler: MessageResponseHandler): Promise { + try { + await messageResponseHandler.send(AiroticProtocol.createHelloMessage(), 500); + return true; + } catch (e: unknown) { + const error = BaseError.normalize(e); + this.logger.info(`Handshake failed: ${error.message}`); + } + + return false; + } + + private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo + { + return 'peripheral' in deviceInfo; + } +} diff --git a/src/device/protocol/airotic/airtonicProtocol.ts b/src/device/protocol/airotic/airtonicProtocol.ts new file mode 100644 index 00000000..e8950255 --- /dev/null +++ b/src/device/protocol/airotic/airtonicProtocol.ts @@ -0,0 +1,26 @@ +import DeviceProtocol, { DecodeResult, MessageResponse } from '../deviceProtocol.js'; + +export default class AiroticProtocol implements DeviceProtocol> +{ + public encode(message: string): Buffer { + return Buffer.from(message, 'utf8'); + } + + public decode(data: Buffer): DecodeResult { + return { + message: data.toString('utf8'), + } + } + + public isResponseMatchingMessage(response: string, message: MessageResponse): boolean { + if (message.message === '!H' && response.startsWith('Hello I am bottle')) { + return true; + } + + return false; + } + + public static createHelloMessage(): MessageResponse { + return { message: '!H' }; + } +} diff --git a/src/device/protocol/messageResponseHandler.ts b/src/device/protocol/messageResponseHandler.ts index 74c34954..5be224d9 100644 --- a/src/device/protocol/messageResponseHandler.ts +++ b/src/device/protocol/messageResponseHandler.ts @@ -8,6 +8,7 @@ type PendingEntry = { resolve: (response: InferResponse) => void; reject: (error: Error) => void; timeout: ReturnType; + timeoutMs: number, pendingSince: number, }; @@ -62,7 +63,7 @@ export default class MessageResponseHandler

200) { + if (responseTime > entry.timeoutMs * 0.8) { this.logger.warn( `Slow response time (${responseTime}ms) for message: ${JSON.stringify(entry.msg.message)}` ); @@ -90,6 +91,7 @@ export default class MessageResponseHandler

${timeoutMs}ms) waiting for response for message: ${encodedMsg.toString()}` )); }, timeoutMs), + timeoutMs, pendingSince: Date.now(), }; diff --git a/src/device/provider/deviceProviderLoader.ts b/src/device/provider/deviceProviderLoader.ts index 63cf5799..32d0dc90 100644 --- a/src/device/provider/deviceProviderLoader.ts +++ b/src/device/provider/deviceProviderLoader.ts @@ -1,8 +1,6 @@ import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; -import DeviceManager from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; -import SerialPortObserver from '../transport/serialPortObserver.js'; export default class DeviceProviderLoader { @@ -10,24 +8,16 @@ export default class DeviceProviderLoader private factories: Map; - private readonly deviceManager: DeviceManager; - - private readonly serialPortObserver: SerialPortObserver; - private readonly logger: Logger; public constructor( - deviceManager: DeviceManager, - serialPortObserver: SerialPortObserver, settings: Settings, factories: Map, logger: Logger ) { - this.deviceManager = deviceManager; - this.serialPortObserver = serialPortObserver; this.settings = settings; this.factories = factories; - this.logger = logger; + this.logger = logger.child({ name: DeviceProviderLoader.name }); } public async loadFromSettings(): Promise @@ -40,7 +30,7 @@ export default class DeviceProviderLoader const factory = this.factories.get(deviceSource.type) if (undefined === factory) { - this.logger.info(`Device source with id ${id} and type ${deviceSource.type} is not supported`); + this.logger.warn(`Device source with id ${id} and type ${deviceSource.type} is not supported`); continue; } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 629cf615..bc816552 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -6,12 +6,13 @@ import { SerialPort, SerialPortOpenOptions } from 'serialport'; import SerialPortFactory from '../../factory/serialPortFactory.js'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; -import DeviceManager, { DeviceInfo, DeviceManagerEvent, SerialDeviceInfo } from '../deviceManager.js'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; import { AnyDeviceConfig } from '../deviceConfig.js'; import { DeviceAttributes } from '../device.js'; import { asyncHandler } from '../../util/async.js'; import { logError } from '../../util/error.js'; +import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts new file mode 100644 index 00000000..f5b01eed --- /dev/null +++ b/src/device/transport/bleDeviceTransport.ts @@ -0,0 +1,78 @@ +import { Characteristic, Peripheral } from '@stoprocent/noble'; +import DeviceTransport from './deviceTransport.js'; + +export default class BleUartDeviceTransport implements DeviceTransport +{ + private readonly peripheral: Peripheral; + private readonly rx: Characteristic; + private readonly tx: Characteristic; + + private isConnected: boolean = true; + + private onCloseSubscribers: (() => Promise)[] = []; + + public static async create( + peripheral: Peripheral, + uartRxCharacteristicUuid: string, + uartTxCharacteristicUuid: string + ): Promise { + await peripheral.connectAsync(); + + const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync( + [/* AiroticDeviceProvider.UART_SERVICE_UUID */], + [uartRxCharacteristicUuid, uartTxCharacteristicUuid], + ); + + const rx = characteristics.find((c) => c.uuid === uartRxCharacteristicUuid); + const tx = characteristics.find((c) => c.uuid === uartTxCharacteristicUuid); + + if (!rx || !tx) { + throw new Error('Missing UART RX/TX characteristics on device.'); + } + + await tx.subscribeAsync(); + + return await new this(peripheral, rx, tx); + } + + private constructor(peripheral: Peripheral, rx: Characteristic, tx: Characteristic) { + this.peripheral = peripheral; + this.rx = rx; + this.tx = tx; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public sendAndAwaitReceive(data: Buffer, timeout?: number): Promise { + throw new Error('Method not implemented.'); + } + + public async send(data: Buffer): Promise { + await this.rx.writeAsync(data, false); + } + + public onReceive(dataProcessor: (data: Buffer) => void): void { + this.tx.on('data', dataProcessor); + } + + public onClose(callback: () => Promise): void { + this.onCloseSubscribers.push(callback); + } + + public isOpen(): boolean { + return this.isConnected; + } + + public async close(): Promise { + await this.tx.unsubscribeAsync(); + await this.peripheral.disconnectAsync(); + this.isConnected = false; + + for (const callback of this.onCloseSubscribers) { + await callback(); + } + } + + public getDeviceIdentifier(): string { + return this.peripheral.id; + } +} \ No newline at end of file diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts new file mode 100644 index 00000000..0e4996d7 --- /dev/null +++ b/src/device/transport/bleObserver.ts @@ -0,0 +1,67 @@ +import noble, { Peripheral } from '@stoprocent/noble'; +import Logger from '../../logging/Logger.js'; +import DeviceManager, { DeviceInfo } from '../deviceManager.js'; +import { logError } from '../../util/error.js'; + +export type BleDeviceInfo = DeviceInfo & { + peripheral: Peripheral; +}; + +export default class BleObserver +{ + private static readonly DEVICE_TIMEOUT = 10000; + private static readonly MIN_RSSI = -70; + private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; + + private readonly deviceManager: DeviceManager; + + private readonly logger: Logger; + + private readonly announcedDevices: Map = new Map(); + + public constructor( + deviceManager: DeviceManager, + logger: Logger + ) { + this.deviceManager = deviceManager; + this.logger = logger.child({ name: BleObserver.name }); + } + + public async init(): Promise + { + noble.on('discover', this.onDiscover.bind(this)); + + await this.observe(); + } + + private onDiscover(peripheral: Peripheral): void + { + if (peripheral.rssi < BleObserver.MIN_RSSI) { + // Ignore devices with very weak signal, as they are likely not in range or just noise + this.logger.debug(`Ignoring device ${peripheral.id} with weak signal (RSSI: ${peripheral.rssi})`); + return; + } + + const deviceInfo: BleDeviceInfo = { + id: peripheral.id, + peripheral, + }; + + this.deviceManager.announceDetectedDevice(deviceInfo); + this.announcedDevices.set(peripheral.id, peripheral); + } + + private async observe(): Promise { + try { + // Wait for Adapter poweredOn state + await noble.waitForPoweredOnAsync(); + // Start scanning first + await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true); + + this.logger.info('Looking for BLE UART devices'); + } catch (error: unknown) { + logError(this.logger, 'BLE device discovery error', error); + await noble.stopScanningAsync(); + } + } +} diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 02755ad7..f187d300 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -1,9 +1,14 @@ import { SerialPort } from 'serialport'; +import { PortInfo } from '@serialport/bindings-interface'; import Logger from '../../logging/Logger.js'; -import DeviceManager, { SerialDeviceInfo } from '../deviceManager.js'; +import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import { setIntervalAsync } from '../../util/async.js'; import { logError } from '../../util/error.js'; +export type SerialDeviceInfo = DeviceInfo & { + portInfo: PortInfo; +}; + export default class SerialPortObserver { protected readonly logger: Logger; diff --git a/src/index.ts b/src/index.ts index 252e7272..bf6aa0ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ const logger = container.get('logger.default'); const io = container.get('server.websocket'); const deviceManager = container.get('device.manager'); const serialPortObserver = container.get('device.observer.serial'); +const bleObserver = container.get('device.observer.ble'); const settingsManager = container.get('settings.manager'); const scriptRuntime = container.get('automation.scriptRuntime'); @@ -128,6 +129,7 @@ const serializer = container.get('serializer.classToPlain'); const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); void serialPortObserver.init(); +void bleObserver.init(); deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 499bec91..a1b9aede 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -44,6 +44,7 @@ import VirtualDeviceFactory from './device/protocol/virtual/virtualDeviceFactory import SerialPortFactory from './factory/serialPortFactory.js'; import Estim2bDeviceFactory from './device/protocol/estim2b/estim2bDeviceFactory.js'; import EventEmitterFactory from './factory/eventEmitterFactory.js'; +import BleObserver from './device/transport/bleObserver.js'; type ServiceMap = { @@ -68,12 +69,14 @@ type ServiceMap = { 'device.provider.factory.zc95Serial': DeviceProviderFactory, 'device.provider.factory.estim2bSerial': DeviceProviderFactory, 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, + 'device.provider.factory.airotic': DeviceProviderFactory, 'device.serial.factory.buttplugIo': ButtplugIoDeviceFactory, 'device.virtual.provider': VirtualDeviceProvider, 'device.virtual.factory': VirtualDeviceFactory, 'device.uniqueNameGenerator': DeviceNameGenerator, 'device.updater': DeviceUpdaterInterface, 'device.observer.serial': SerialPortObserver, + 'device.observer.ble': BleObserver, /* factoryServiceProvider */ 'factory.uuid': UuidFactory, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index b569a47d..0ee2d70a 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -36,6 +36,8 @@ import GenericVirtualDeviceLogicFactory from '../device/protocol/virtual/generic import GenericDeviceProviderFactory from '../device/provider/genericDeviceProviderFactory.js'; import EStim2bSerialDeviceProvider from '../device/protocol/estim2b/estim2bSerialDeviceProvider.js'; import Estim2bDeviceFactory from '../device/protocol/estim2b/estim2bDeviceFactory.js'; +import BleObserver from '../device/transport/bleObserver.js'; +import AiroticDeviceProvider from '../device/protocol/airotic/airoticDeviceProvider.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -171,29 +173,31 @@ export default class DeviceServiceProvider implements ServiceProvider { return new DeviceProviderLoader( - container.get('device.manager'), - container.get('device.observer.serial'), container.get('settings'), new Map([ [ SlvCtrlPlusSerialDeviceProvider.providerName, - container.get('device.provider.factory.slvCtrlPlusSerial') + container.get('device.provider.factory.slvCtrlPlusSerial'), ], [ ButtplugIoWebsocketDeviceProvider.providerName, - container.get('device.provider.factory.buttplugIoWebsocket') + container.get('device.provider.factory.buttplugIoWebsocket'), ], [ VirtualDeviceProvider.providerName, - container.get('device.provider.factory.virtual') + container.get('device.provider.factory.virtual'), ], [ Zc95SerialDeviceProvider.providerName, - container.get('device.provider.factory.zc95Serial') + container.get('device.provider.factory.zc95Serial'), ], [ EStim2bSerialDeviceProvider.providerName, - container.get('device.provider.factory.estim2bSerial') + container.get('device.provider.factory.estim2bSerial'), + ], + [ + AiroticDeviceProvider.providerName, + container.get('device.provider.factory.airotic'), ], ]), container.get('logger.default'), @@ -224,11 +228,27 @@ export default class DeviceServiceProvider implements ServiceProvider { + return new GenericDeviceProviderFactory( + AiroticDeviceProvider, + container.get('device.manager'), + container.get('factory.eventEmitter').create(), + container.get('logger.default'), + ); + }); + container.set('device.observer.serial', () => { return new SerialPortObserver( container.get('device.manager'), container.get('logger.default') ); - }) + }); + + container.set('device.observer.ble', () => { + return new BleObserver( + container.get('device.manager'), + container.get('logger.default') + ); + }); } } From 4f55b1f571b316b42b68a90563124bda41d5fc09 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 21 Mar 2026 14:33:00 +0100 Subject: [PATCH 02/42] wip with DeviceId --- src/controller/getDevicesController.ts | 6 +- src/device/device.ts | 7 +- src/device/deviceId.ts | 23 ++++++ src/device/deviceManager.ts | 49 +++++++------ src/device/peripheralDevice.ts | 3 +- src/device/protocol/airotic/airoticDevice.ts | 47 ++++++++++++- .../protocol/airotic/airoticDeviceProvider.ts | 70 +++++++++++++++++-- .../protocol/buttplugIo/buttplugIoDevice.ts | 3 +- .../buttplugIo/buttplugIoDeviceFactory.ts | 12 ++-- src/device/protocol/estim2b/estim2bDevice.ts | 3 +- .../protocol/estim2b/estim2bDeviceFactory.ts | 9 +-- .../estim2b/estim2bSerialDeviceProvider.ts | 24 ++----- .../slvCtrlPlus/genericSlvCtrlPlusDevice.ts | 3 +- .../protocol/slvCtrlPlus/slvCtrlPlusDevice.ts | 3 +- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 20 ++---- .../slvCtrlPlusSerialDeviceProvider.ts | 23 ++---- src/device/protocol/virtual/virtualDevice.ts | 5 +- .../protocol/virtual/virtualDeviceProvider.ts | 6 +- src/device/protocol/zc95/zc95Device.ts | 3 +- src/device/protocol/zc95/zc95DeviceFactory.ts | 9 +-- .../protocol/zc95/zc95SerialDeviceProvider.ts | 24 ++----- src/device/provider/serialDeviceProvider.ts | 26 +++++-- src/device/transport/bleObserver.ts | 4 +- src/device/transport/serialPortObserver.ts | 3 +- src/factory/uuidFactory.ts | 23 +++++- .../discriminator/deviceDiscriminator.ts | 2 + src/serviceProvider/deviceServiceProvider.ts | 5 +- src/serviceProvider/factoryServiceProvider.ts | 2 +- src/settings/knownDevice.ts | 18 ++--- src/settings/settings.ts | 13 ++-- src/settings/settingsManager.ts | 4 +- src/util/createMapTransformFn.ts | 9 ++- 32 files changed, 288 insertions(+), 173 deletions(-) create mode 100644 src/device/deviceId.ts diff --git a/src/controller/getDevicesController.ts b/src/controller/getDevicesController.ts index d17d8092..7081af67 100644 --- a/src/controller/getDevicesController.ts +++ b/src/controller/getDevicesController.ts @@ -20,6 +20,10 @@ export default class GetDevicesController implements ControllerInterface { const list = new DeviceList(this.connectedDeviceRepository.getAll()); - res.json(this.serializer.transform(list)); + const plain = this.serializer.transform(list); + + //console.log(plain); + + res.json(plain); } } diff --git a/src/device/device.ts b/src/device/device.ts index 78111190..eca49910 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -3,6 +3,7 @@ import DeviceState from './deviceState.js'; import DeviceAttribute from './attribute/deviceAttribute.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import { EventEmitter } from 'events'; +import DeviceId from './deviceId.js'; export type InferDeviceAttributes> = D extends Device ? TAttrs : DeviceAttributes; @@ -45,7 +46,7 @@ export default abstract class Device< protected readonly connectedSince: Date; @Expose() - protected readonly deviceId: string; + protected readonly deviceId: DeviceId; @Expose() protected readonly deviceName: string; @@ -77,7 +78,7 @@ export default abstract class Device< private eventEmitter: EventEmitter; protected constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, provider: string, connectedSince: Date, @@ -97,7 +98,7 @@ export default abstract class Device< this.state = DeviceState.ready; } - public get getDeviceId(): string { + public get getDeviceId(): DeviceId { return this.deviceId; } diff --git a/src/device/deviceId.ts b/src/device/deviceId.ts new file mode 100644 index 00000000..9a151c17 --- /dev/null +++ b/src/device/deviceId.ts @@ -0,0 +1,23 @@ +import { v5 as uuidv5 } from 'uuid'; + +export default class DeviceId +{ + private static readonly DEVICE_NAMESPACE = '1e0758c9-799d-40b5-b2fc-63f1e66afb76'; + + private readonly id: string; + + public constructor(seed: string) + { + this.id = uuidv5(seed, DeviceId.DEVICE_NAMESPACE); + } + + public toString(): string + { + return this.id; + } + + public toJSON(): string + { + return this.id; + } +} diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 134d57f0..43cf6efd 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -5,9 +5,10 @@ import { setIntervalAsync } from '../util/async.js'; import Logger from '../logging/Logger.js'; import { AnyDeviceConfig } from './deviceConfig.js'; import { logError } from '../util/error.js'; +import DeviceId from './deviceId.js'; export type DeviceInfo = { - id: string; + id: DeviceId; }; export enum DeviceManagerEvent { @@ -46,33 +47,35 @@ export default class DeviceManager public announceDetectedDevice(deviceInfo: DeviceInfo): void { - if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)) { + if (this.detectedDeviceAcquireQueue.has(deviceInfo.id.toString())) { return; } - this.detectedDeviceAcquireQueue.set(deviceInfo.id, []); + this.logger.info(`Announced new BLE device with id ${deviceInfo.id.toString()}`); + + this.detectedDeviceAcquireQueue.set(deviceInfo.id.toString(), []); const hadListeners = this.eventEmitter.emit(DeviceManagerEvent.deviceDetected, deviceInfo); if (!hadListeners) { // no subscribed providers, remove empty list from acquire queue for this device - this.logger.info(`No provider available for detected device with id '${deviceInfo.id}'`); - this.detectedDeviceAcquireQueue.delete(deviceInfo.id); + this.logger.info(`No provider available for detected device with id '${deviceInfo.id.toString()}'`); + this.detectedDeviceAcquireQueue.delete(deviceInfo.id.toString()); } } public revokeDetectedDevice(deviceInfo: DeviceInfo): void { - this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); + this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id.toString()}' has disappeared`); } - public async acquireDetectedDevice(deviceId: string): Promise + public async acquireDetectedDevice(deviceId: DeviceId): Promise { return new Promise((resolve) => { - const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); + const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId.toString()); if (undefined === deviceQueue) { - resolve({ successful: false, reason: `Device with id '${deviceId}' is not available for claiming` }); + resolve({ successful: false, reason: `Device with id '${deviceId.toString()}' is not available for claiming` }); return; } @@ -86,9 +89,9 @@ export default class DeviceManager }); } - public releaseDetectedDevice(deviceId: string): void + public releaseDetectedDevice(deviceId: DeviceId): void { - const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); + const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId.toString()); if (undefined === deviceQueue) { return; @@ -98,7 +101,7 @@ export default class DeviceManager deviceQueue.shift(); if (deviceQueue.length === 0) { - this.detectedDeviceAcquireQueue.delete(deviceId); + this.detectedDeviceAcquireQueue.delete(deviceId.toString()); return; } @@ -109,7 +112,7 @@ export default class DeviceManager device: Device ): void { - this.connectedDevices.set(device.getDeviceId, device); + this.connectedDevices.set(device.getDeviceId.toString(), device); device.on(DeviceEvent.deviceRefreshed, (d: Device) => this.refreshDevice(d)); device.on(DeviceEvent.deviceDisconnected, (d: Device) => this.removeDevice(d)); @@ -119,9 +122,9 @@ export default class DeviceManager this.eventEmitter.emit(DeviceManagerEvent.deviceConnected, device); } - public claimDetectedDevice(deviceId: string): void + public claimDetectedDevice(deviceId: DeviceId): void { - this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId}' has been claimed by another provider`); + this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId.toString()}' has been claimed by another provider`); } public getConnectedDevices(): Device[] @@ -144,17 +147,17 @@ export default class DeviceManager this.eventEmitter.on(event, listener); } - private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void + private clearDetectedDeviceAcquireQueue(deviceId: DeviceId, reason: string): void { - for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { + for (const entry of this.detectedDeviceAcquireQueue.get(deviceId.toString()) ?? []) { entry.resolve({ successful: false, reason }); } - this.detectedDeviceAcquireQueue.delete(deviceId); + this.detectedDeviceAcquireQueue.delete(deviceId.toString()); } private initDeviceRefresher(device: Device): void { - this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId})`); + this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId.toString()})`); const deviceRefreshIntervalMs = device.getRefreshInterval; if (undefined === deviceRefreshIntervalMs) { @@ -163,19 +166,19 @@ export default class DeviceManager const deviceRefresher = async (): Promise => { if (device.getState === DeviceState.busy) { - this.logger.trace(`Device not refreshed since it's currently busy: ${device.getDeviceId}`); + this.logger.trace(`Device not refreshed since it's currently busy: ${device.getDeviceId.toString()}`); return; } await device.refresh(); - this.logger.trace(`device: ${device.getDeviceId} -> refresh -> successful`); + this.logger.trace(`device: ${device.getDeviceId.toString()} -> refresh -> successful`); }; const deviceRefreshInterval = setIntervalAsync(deviceRefresher, { intervalMs: deviceRefreshIntervalMs, timeoutMs: deviceRefreshIntervalMs * 3, - onError: (e: unknown) => logError(this.logger, `device: ${device.getDeviceId} -> refresh -> failed`, e), + onError: (e: unknown) => logError(this.logger, `device: ${device.getDeviceId.toString()} -> refresh -> failed`, e), }); device.on(DeviceEvent.deviceDisconnected, () => deviceRefreshInterval.clear()); @@ -183,7 +186,7 @@ export default class DeviceManager private removeDevice(device: Device): void { - this.connectedDevices.delete(device.getDeviceId); + this.connectedDevices.delete(device.getDeviceId.toString()); this.eventEmitter.emit(DeviceManagerEvent.deviceDisconnected, device); } diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 5962bb97..70162982 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -3,6 +3,7 @@ import DeviceTransport from './transport/deviceTransport.js'; import DeviceProtocol, { MessageResponse } from './protocol/deviceProtocol.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import EventEmitter from 'events'; +import DeviceId from './deviceId.js'; export type InferPeripheralDeviceAttributes> = D extends PeripheralDevice ? TAttrs : DeviceAttributes; @@ -21,7 +22,7 @@ export default abstract class PeripheralDevice< protected readonly protocol: TProtocol; protected constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, provider: string, connectedSince: Date, diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 20348e52..d603010d 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -1,8 +1,53 @@ +import { Exclude, Expose } from 'class-transformer'; +import EventEmitter from 'events'; import { Int, Float } from '../../../util/numbers.js'; import Device from '../../device.js'; +import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; +import { NoDeviceConfig } from '../../deviceConfig.js'; +import { Peripheral } from '@stoprocent/noble'; +import DeviceId from '../../deviceId.js'; -export default class AiroticDevice extends Device +export type AiroticDeviceAttributes = { + color: StrDeviceAttribute, +}; + +@Exclude() +export default class AiroticDevice extends Device { + private readonly peripheral: Peripheral; + + @Expose() + private rssi: number; + + public constructor( + deviceId: DeviceId, + deviceName: string, + provider: string, + peripheral: Peripheral, + connectedSince: Date, + controllable: boolean, + attributes: AiroticDeviceAttributes, + config: NoDeviceConfig, + eventEmitter: EventEmitter + ) { + super(deviceId, deviceName, provider, connectedSince, controllable, attributes, config, eventEmitter); + + this.peripheral = peripheral; + this.rssi = peripheral.rssi; + + setInterval(() => { + peripheral.updateRssi((error, rssi) => { + if (error) { + console.error(`Error updating RSSI for device ${this.deviceId.toString()}`, error); + return; + } + + this.rssi = rssi; + this.updateLastRefresh(); + }); + }, 5000); + } + public override setAttribute(attributeName: K, value: V): Promise { console.log(attributeName, value); throw new Error('Method not implemented.'); diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index a099a87d..14565a13 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -10,6 +10,12 @@ import { BleDeviceInfo } from '../../transport/bleObserver.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airtonicProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; +import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; +import { Peripheral } from '@stoprocent/noble'; +import Settings from '../../../settings/settings.js'; +import KnownDevice from '../../../settings/knownDevice.js'; +import DeviceId from '../../deviceId.js'; export default class AiroticDeviceProvider extends DeviceProvider { @@ -19,9 +25,13 @@ export default class AiroticDeviceProvider extends DeviceProvider private static readonly UART_RX_CHAR_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; private static readonly UART_TX_CHAR_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; - public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + private readonly settings: Settings; + + public constructor(deviceManager: DeviceManager, settings: Settings, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger.child({ name: AiroticDeviceProvider.name })); + this.settings = settings; + this.deviceManager.on( DeviceManagerEvent.deviceDetected, asyncHandler( @@ -37,38 +47,67 @@ export default class AiroticDeviceProvider extends DeviceProvider } private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + this.logger.debug(`Handling detected device with id ${deviceInfo.id.toString()}`); + if (!this.isBleDeviceInfo(deviceInfo)) { return; } try { - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id.toString()}`); await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - this.logger.debug(`Connected to device: ${deviceInfo.id}`); - const transport = await BleUartDeviceTransport.create( deviceInfo.peripheral, AiroticDeviceProvider.UART_RX_CHAR_UUID, AiroticDeviceProvider.UART_TX_CHAR_UUID ); + this.logger.debug(`Connected to device: ${deviceInfo.id.toString()}`); + const protocol = new AiroticProtocol(); const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger); const handshakeResult = await this.doHandshake(messageResponseHandler); - console.log(`Handshake result: ${handshakeResult}, create device and add to device manager!`); + if (handshakeResult) { + this.deviceManager.addDevice(this.createDevice(deviceInfo, deviceInfo.peripheral, protocol)); - this.deviceManager.claimDetectedDevice(deviceInfo.id); + this.deviceManager.claimDetectedDevice(deviceInfo.id); + } else { + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + } } catch (e: unknown) { logError(this.logger, `Error while connecting to device`, e); this.deviceManager.releaseDetectedDevice(deviceInfo.id); } } + private createDevice(deviceInfo: BleDeviceInfo, peripheral: Peripheral, protocol: AiroticProtocol): AiroticDevice + { + const knownDevice = this.createKnownDevice(deviceInfo.id, deviceInfo.peripheral.advertisement.localName ?? `Airotic ${deviceInfo.id.toString()}`, AiroticDeviceProvider.providerName); + + const device = new AiroticDevice( + knownDevice.id, + knownDevice.name, + AiroticDeviceProvider.providerName, + peripheral, + new Date(), + true, + { + color: new StrDeviceAttribute('color', 'Color', DeviceAttributeModifier.readWrite, '') + }, + {}, + new EventEmitter(), + ); + + this.settings.addKnownDevice(knownDevice); + + return device; + } + private async doHandshake(messageResponseHandler: MessageResponseHandler): Promise { try { await messageResponseHandler.send(AiroticProtocol.createHelloMessage(), 500); @@ -81,6 +120,25 @@ export default class AiroticDeviceProvider extends DeviceProvider return false; } + + private createKnownDevice(deviceId: DeviceId, deviceName: string, provider: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId) + + if (undefined !== knownDevice) { + // Return already existing device if already known (previously detected serial number) + this.logger.debug(`Device is already known: ${knownDevice.id.toString()}`); + return knownDevice; + } + + // Create a new device and return if not yet known (new serial number) + return new KnownDevice( + deviceId, + deviceName, + 'airotic', + provider + ); + } + private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { return 'peripheral' in deviceInfo; diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 55e484e7..3a8e09fa 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -7,6 +7,7 @@ import { Int } from '../../../util/numbers.js'; import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import EventEmitter from 'events'; +import DeviceId from '../../deviceId.js'; type ButtplugActuatorTypeKey = `${ActuatorType}-${number}`; type ButtplugSensorTypeKey = `${SensorType}-${number}`; @@ -26,7 +27,7 @@ export default class ButtplugIoDevice extends Device private deviceModel: string; public constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, deviceModel: string, provider: string, diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index c45eacb8..e1ed9b1b 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -1,4 +1,3 @@ -import UuidFactory from '../../../factory/uuidFactory.js'; import Settings from '../../../settings/settings.js'; import { ButtplugClientDevice } from 'buttplug'; import ButtplugIoDevice, { ButtplugIoDeviceAttributeKey, ButtplugIoDeviceAttributes } from './buttplugIoDevice.js'; @@ -11,12 +10,11 @@ import DateFactory from '../../../factory/dateFactory.js'; import { Int } from '../../../util/numbers.js'; import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; +import DeviceId from '../../deviceId.js'; export default class ButtplugIoDeviceFactory { - private readonly uuidFactory: UuidFactory; - private readonly dateFactory: DateFactory; private readonly settings: Settings; @@ -25,8 +23,7 @@ export default class ButtplugIoDeviceFactory private readonly eventEmitterFactory: EventEmitterFactory; - public constructor(uuidFactory: UuidFactory, dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, settings: Settings, logger: Logger) { - this.uuidFactory = uuidFactory; + public constructor(dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, settings: Settings, logger: Logger) { this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; @@ -118,18 +115,17 @@ export default class ButtplugIoDeviceFactory // we need to use the index assigned to the device by Intiface. It's the best we have. // or the name if using Intiface-engine without id persistence const nameString = buttplugDevice.name.replace(/[^a-zA-Z0-9]/g, ''); - const deviceId = useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`; + const deviceId = new DeviceId(useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); const knownDevice = this.settings.getKnownDeviceById(deviceId) if (undefined !== knownDevice) { // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id} (${deviceId})`); + this.logger.debug(`Device is already known: ${knownDevice.id.toString()}`); return knownDevice; } return new KnownDevice( - this.uuidFactory.create(), deviceId, buttplugDevice.displayName ?? buttplugDevice.name, buttplugDevice.name, diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index a36b0b7e..13f44515 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -12,6 +12,7 @@ import PeripheralDevice from '../../peripheralDevice.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; +import DeviceId from '../../deviceId.js'; export type EStim2bDeviceAttributes = { mode: ListDeviceAttribute, @@ -36,7 +37,7 @@ export default class EStim2bDevice extends PeripheralDevice { @@ -19,8 +18,6 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider = new Map(); - private readonly deviceFactory: EStim2bDeviceFactory; public constructor( @@ -37,9 +34,9 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPort): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); - const syncPort = new SynchronousSerialPort(portInfo, parser, port, this.logger); + const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); const transport = this.transportFactory.create(syncPort, undefined, Buffer.from('\r')); const estim2bProtocol = new EStim2bProtocol(); @@ -53,27 +50,16 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { - this.connectedDevices.delete(device.getDeviceId); - - this.logger.info('Lost serial device: ' + device.getDeviceId); - this.logger.info('Connected E-Stim Systems 2B serial devices: ' + this.connectedDevices.size.toString()); - }); - return device; } diff --git a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts index 4cd59a89..fbbc6950 100644 --- a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts @@ -6,6 +6,7 @@ import SlvCtrlProtocol from './slvCtrlProtocol.js'; import DeviceTransport from '../../transport/deviceTransport.js'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; +import DeviceId from '../../deviceId.js'; @Exclude() export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice @@ -23,7 +24,7 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice public constructor( fwVersion: number, - deviceId: string, + deviceId: DeviceId, deviceName: string, deviceModel: string, provider: string, diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts index b6a8f76a..360da672 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts @@ -7,6 +7,7 @@ import PeripheralDevice from '../../peripheralDevice.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; +import DeviceId from '../../deviceId.js'; export type SlvCtrlPlusDeviceAttributeKey = string; export type SlvCtrlPlusDeviceAttributes = Record; @@ -19,7 +20,7 @@ export default abstract class SlvCtrlPlusDevice< protected readonly logger: Logger; protected constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, provider: string, connectedSince: Date, diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index 9eaf42a4..44b76773 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -1,4 +1,3 @@ -import UuidFactory from '../../../factory/uuidFactory.js'; import Settings from '../../../settings/settings.js'; import KnownDevice from '../../../settings/knownDevice.js'; import DeviceNameGenerator from '../../deviceNameGenerator.js'; @@ -12,11 +11,10 @@ import SlvCtrlProtocol, { DeviceInfo } from './slvCtrlProtocol.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; +import DeviceId from '../../deviceId.js'; export default class SlvCtrlPlusDeviceFactory { - private readonly uuidFactory: UuidFactory; - private readonly dateFactory: DateFactory; protected readonly eventEmitterFactory: EventEmitterFactory; @@ -28,14 +26,12 @@ export default class SlvCtrlPlusDeviceFactory private readonly logger: Logger; public constructor( - uuidFactory: UuidFactory, dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, settings: Settings, nameGenerator: DeviceNameGenerator, logger: Logger ) { - this.uuidFactory = uuidFactory; this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; this.settings = settings; @@ -43,11 +39,10 @@ export default class SlvCtrlPlusDeviceFactory this.logger = logger.child({ name: SlvCtrlPlusDeviceFactory.name }); } - public async create(transport: DeviceTransport, provider: string): Promise { + public async create(deviceId: DeviceId, transport: DeviceTransport, provider: string): Promise { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; - const deviceIdentifier = transport.getDeviceIdentifier(); - const knownDevice = this.createKnownDevice(deviceIdentifier, deviceInfo.deviceType, provider); + const knownDevice = this.createKnownDevice(deviceId, deviceInfo.deviceType, provider); const deviceAttributes = await this.getAttributes(transport, protocol); const device = new GenericSlvCtrlPlusDevice( @@ -131,19 +126,18 @@ export default class SlvCtrlPlusDeviceFactory return new SlvCtrlProtocolV1(); } - private createKnownDevice(serialNo: string, deviceType: string, provider: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(serialNo) + private createKnownDevice(deviceId: DeviceId, deviceType: string, provider: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId) if (undefined !== knownDevice) { // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id} (${serialNo})`); + this.logger.debug(`Device is already known: ${knownDevice.id.toString()}`); return knownDevice; } // Create a new device and return if not yet known (new serial number) return new KnownDevice( - this.uuidFactory.create(), - serialNo, + deviceId, this.nameGenerator.generateName(), deviceType, provider diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts index c2a18867..d074919a 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts @@ -12,6 +12,7 @@ import SlvCtrlProtocol from './slvCtrlProtocol.js'; import DeviceTransport from '../../../device/transport/deviceTransport.js'; import DeviceManager from '../../deviceManager.js'; import GenericSlvCtrlPlusDevice from './genericSlvCtrlPlusDevice.js'; +import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvider { @@ -21,8 +22,6 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide private static readonly arduinoVendorId = '2341'; - private connectedDevices: Map = new Map(); - private readonly slvCtrlPlusDeviceFactory: SlvCtrlPlusDeviceFactory; private readonly deviceTransportFactory: SerialDeviceTransportFactory; @@ -40,31 +39,21 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide this.deviceTransportFactory = deviceTransportFactory; } - protected async connectSerialDevice(port: SerialPort, portInfo: PortInfo): Promise { + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPort): Promise + { const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); - const syncPort = new SynchronousSerialPort(portInfo, parser, port, this.logger); + const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); const transport = this.deviceTransportFactory.create(syncPort, undefined, Buffer.from(SlvCtrlProtocol.EOF)); await this.performHandshakeWithRetries(transport, 4); const device = await this.slvCtrlPlusDeviceFactory.create( + deviceInfo.id, transport, SlvCtrlPlusSerialDeviceProvider.providerName ); - this.logger.info(`Module detected: ${device.getDeviceModel} (${portInfo.serialNumber})`); - - this.connectedDevices.set(device.getDeviceId, device); - - this.logger.debug(`Assigned device id: ${device.getDeviceId} (${portInfo.serialNumber})`); - this.logger.info('Connected devices: ' + this.connectedDevices.size.toString()); - - port.on('close', () => { - this.connectedDevices.delete(device.getDeviceId); - - this.logger.info('Lost serial device: ' + device.getDeviceId); - this.logger.info('Connected SlvCtrl+ serial devices: ' + this.connectedDevices.size.toString()); - }); + this.logger.info(`Module detected: ${device.getDeviceModel} (${deviceInfo.portInfo.serialNumber})`); return device; } diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index 6c0ea7e4..67eafbd6 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -5,6 +5,7 @@ import VirtualDeviceLogic, { ExtractAttributes, ExtractConfig } from './virtualD import { AnyDeviceConfig } from '../../deviceConfig.js'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; +import DeviceId from '../../deviceId.js'; @Exclude() export default class VirtualDevice< @@ -24,7 +25,7 @@ export default class VirtualDevice< public constructor( fwVersion: string, - deviceId: string, + deviceId: DeviceId, deviceName: string, deviceModel: string, provider: string, @@ -71,7 +72,7 @@ export default class VirtualDevice< if (undefined === attribute || null === attribute) { reject(new Error( - `Attribute named "${attributeName.toString()}" does not exist for device with id "${this.deviceId}"` + `Attribute named "${attributeName.toString()}" does not exist for device with id "${this.deviceId.toString()}"` )); return; } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 15c84e1c..25b7e588 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -80,16 +80,16 @@ export default class VirtualDeviceProvider extends DeviceProvider { - const deviceId = device.getDeviceId; + const deviceId = device.getDeviceId.toString(); try { await device.close(); diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index 1e7b4ed8..3b41b4e3 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -24,6 +24,7 @@ import DeviceTransport from '../../transport/deviceTransport.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import Logger from '../../../logging/Logger.js'; import EventEmitter from 'events'; +import DeviceId from '../../deviceId.js'; type RequiredZc95DeviceAttributes = { activePattern: InitializedListDeviceAttribute; @@ -65,7 +66,7 @@ export default class Zc95Device extends PeripheralDevice { public static readonly providerName = 'zc95Serial'; - private connectedDevices: Map = new Map(); - private readonly transportFactory: SerialDeviceTransportFactory; private readonly deviceFactory: Zc95DeviceFactory; @@ -38,11 +36,11 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPort): Promise { const serialLogger = this.logger.child({ name: Zc95Device.name }) const parser = port.pipe(new FrameParser({ stx: Zc95Protocol.STX, etx: Zc95Protocol.ETX })); - const serialPort = new SynchronousSerialPort(portInfo, parser, port, serialLogger); + const serialPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, serialLogger); const transport = this.transportFactory.create( serialPort, Buffer.from([Zc95Protocol.STX]), Buffer.from([Zc95Protocol.ETX]) ); @@ -59,9 +57,10 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { - // clearInterval(deviceStatusUpdaterInterval); - this.connectedDevices.delete(device.getDeviceId); - - this.logger.info('Lost serial device: ' + device.getDeviceId); - this.logger.info('Connected ZC95 serial devices: ' + this.connectedDevices.size.toString()); - }); - return device; } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index bc816552..96c267f6 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -24,6 +24,8 @@ export default abstract class SerialDeviceProvider< { private readonly serialPortFactory: SerialPortFactory; + private connectedDevices: Map = new Map(); + protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); @@ -43,7 +45,7 @@ export default abstract class SerialDeviceProvider< return; } - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id.toString()}`); const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); @@ -53,7 +55,7 @@ export default abstract class SerialDeviceProvider< } try { - const device = await this.connectToDevice(deviceInfo.portInfo); + const device = await this.connectToDevice(deviceInfo); if (undefined === device) { this.deviceManager.releaseDetectedDevice(deviceInfo.id); @@ -73,7 +75,9 @@ export default abstract class SerialDeviceProvider< return 'portInfo' in deviceInfo; } - public async connectToDevice(portInfo: PortInfo): Promise { + private async connectToDevice(deviceInfo: SerialDeviceInfo): Promise { + const portInfo = deviceInfo.portInfo; + this.logger.info(`Connection attempt for serial device '${portInfo.path}' (s/n: ${portInfo.serialNumber})`); const port = this.serialPortFactory.create({ @@ -92,7 +96,7 @@ export default abstract class SerialDeviceProvider< await this.preparePort(port, portInfo); - device = await this.connectSerialDevice(port, portInfo); + device = await this.connectSerialDevice(deviceInfo, port); } catch(e: unknown) { if (undefined !== device) { try { @@ -114,6 +118,18 @@ export default abstract class SerialDeviceProvider< this.logger.info(`Could not connect to serial device '${portInfo.path}': ${attemptFailureReason}`); } else { this.logger.info(`Successfully connected to serial device '${portInfo.path}'`); + + this.connectedDevices.set(device.getDeviceId.toString(), device); + + this.logger.debug(`Assigned device id: ${device.getDeviceId.toString()} (${portInfo.path})`); + this.logger.info('Connected devices: ' + this.connectedDevices.size.toString()); + + port.on('close', () => { + this.connectedDevices.delete(device.getDeviceId.toString()); + + this.logger.info('Lost serial device: ' + device.getDeviceId.toString()); + this.logger.info('Connected devices: ' + this.connectedDevices.size.toString()); + }); } return device; @@ -124,7 +140,7 @@ export default abstract class SerialDeviceProvider< return Promise.resolve(); } - protected abstract connectSerialDevice(port: SerialPort, portInfo: PortInfo): Promise; + protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPort): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 0e4996d7..4ec6e4e7 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -2,6 +2,7 @@ import noble, { Peripheral } from '@stoprocent/noble'; import Logger from '../../logging/Logger.js'; import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; +import DeviceId from '../deviceId.js'; export type BleDeviceInfo = DeviceInfo & { peripheral: Peripheral; @@ -9,7 +10,6 @@ export type BleDeviceInfo = DeviceInfo & { export default class BleObserver { - private static readonly DEVICE_TIMEOUT = 10000; private static readonly MIN_RSSI = -70; private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; @@ -43,7 +43,7 @@ export default class BleObserver } const deviceInfo: BleDeviceInfo = { - id: peripheral.id, + id: new DeviceId(peripheral.id), peripheral, }; diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index f187d300..c98785fd 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -4,6 +4,7 @@ import Logger from '../../logging/Logger.js'; import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import { setIntervalAsync } from '../../util/async.js'; import { logError } from '../../util/error.js'; +import DeviceId from '../deviceId.js'; export type SerialDeviceInfo = DeviceInfo & { portInfo: PortInfo; @@ -61,7 +62,7 @@ export default class SerialPortObserver if (!this.managedDevices.has(portInfo.serialNumber)) { const deviceInfo: SerialDeviceInfo = { - id: portInfo.serialNumber, + id: new DeviceId(portInfo.serialNumber), portInfo }; diff --git a/src/factory/uuidFactory.ts b/src/factory/uuidFactory.ts index 69097720..9ae6e87f 100644 --- a/src/factory/uuidFactory.ts +++ b/src/factory/uuidFactory.ts @@ -1,9 +1,26 @@ -import { v4 as uuidv4 } from 'uuid'; +import { validate as isUuid, v4 as uuidv4, v5 as uuidv5 } from 'uuid'; export default class UuidFactory { - public create(): string + public static readonly DEFAULT_NAMESPACE = '1eb996d5-15ee-4c2c-9a4c-4f84a7dedb56' + + private readonly namespace: string; + + public constructor(namespace: string) { - return uuidv4(); + if (!isUuid(namespace)) { + throw new Error(`Invalid UUID namespace: ${namespace}`); + } + + this.namespace = namespace; + } + + public create(seed?: string): string + { + if (undefined === seed) { + return uuidv4(); + } + + return uuidv5(seed, this.namespace); } } diff --git a/src/serialization/discriminator/deviceDiscriminator.ts b/src/serialization/discriminator/deviceDiscriminator.ts index e30c6309..772ae685 100644 --- a/src/serialization/discriminator/deviceDiscriminator.ts +++ b/src/serialization/discriminator/deviceDiscriminator.ts @@ -4,6 +4,7 @@ import ButtplugIoDevice from '../../device/protocol/buttplugIo/buttplugIoDevice. import VirtualDevice from '../../device/protocol/virtual/virtualDevice.js'; import Zc95Device from '../../device/protocol/zc95/zc95Device.js'; import EStim2bDevice from '../../device/protocol/estim2b/estim2bDevice.js'; +import AiroticDevice from '../../device/protocol/airotic/airoticDevice.js'; export default class DeviceDiscriminator extends ObjectDiscriminator { @@ -13,5 +14,6 @@ export default class DeviceDiscriminator extends ObjectDiscriminator { value: Zc95Device, name: 'zc95' }, { value: VirtualDevice, name: 'virtual' }, { value: EStim2bDevice, name: 'estim2b' }, + { value: AiroticDevice, name: 'airotic' }, ]; } diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 0ee2d70a..f6726b62 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -89,7 +89,6 @@ export default class DeviceServiceProvider implements ServiceProvider new SlvCtrlPlusDeviceFactory( - container.get('factory.uuid'), container.get('factory.date'), container.get('factory.eventEmitter'), container.get('settings'), @@ -98,7 +97,6 @@ export default class DeviceServiceProvider implements ServiceProvider new ButtplugIoDeviceFactory( - container.get('factory.uuid'), container.get('factory.date'), container.get('factory.eventEmitter'), container.get('settings'), @@ -106,7 +104,6 @@ export default class DeviceServiceProvider implements ServiceProvider new Zc95DeviceFactory( - container.get('factory.uuid'), container.get('factory.date'), container.get('factory.eventEmitter'), container.get('settings'), @@ -115,7 +112,6 @@ export default class DeviceServiceProvider implements ServiceProvider new Estim2bDeviceFactory( - container.get('factory.uuid'), container.get('factory.date'), container.get('factory.eventEmitter'), container.get('settings'), @@ -232,6 +228,7 @@ export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { - container.set('factory.uuid', () => new UuidFactory()); + container.set('factory.uuid', () => new UuidFactory(UuidFactory.DEFAULT_NAMESPACE)); container.set('factory.date', () => new DateFactory()); container.set('factory.serialPort', () => new SerialPortFactory()); container.set('factory.eventEmitter', () => new EventEmitterFactory()); diff --git a/src/settings/knownDevice.ts b/src/settings/knownDevice.ts index 66b2f21c..aafba1cd 100644 --- a/src/settings/knownDevice.ts +++ b/src/settings/knownDevice.ts @@ -1,14 +1,13 @@ -import { Exclude, Expose } from 'class-transformer'; +import { Exclude, Expose, Type } from 'class-transformer'; import { JsonObject } from '../types.js'; +import DeviceId from '../device/deviceId.js'; @Exclude() export default class KnownDevice { @Expose({ name: 'id' }) - private readonly _id: string; - - @Expose({ name: 'serialNo' }) - private readonly _serialNo: string; + @Type(() => DeviceId) + private readonly _id: DeviceId; @Expose({ name: 'name' }) private readonly _name: string; @@ -23,24 +22,19 @@ export default class KnownDevice private readonly _config: JsonObject; public constructor( - id: string, serialNo: string, name: string, type: string, source: string, config: JsonObject = {} + id: DeviceId, name: string, type: string, source: string, config: JsonObject = {} ) { this._id = id; - this._serialNo = serialNo; this._name = name; this._type = type; this._source = source; this._config = config; } - public get id(): string { + public get id(): DeviceId { return this._id; } - public get serialNo(): string { - return this._serialNo; - } - public get name(): string { return this._name; } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 446a94f0..48d5934c 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -2,6 +2,7 @@ import { Exclude, Expose, Transform } from 'class-transformer'; import KnownDevice from './knownDevice.js'; import createMapTransformFn from '../util/createMapTransformFn.js'; import DeviceSource from './deviceSource.js'; +import DeviceId from '../device/deviceId.js'; @Exclude() export default class Settings @@ -39,19 +40,15 @@ export default class Settings return filteredDevices; } - public getKnownDeviceById(id: string): KnownDevice|undefined + public getKnownDeviceById(id: DeviceId): KnownDevice|undefined { - if (this.knownDevices.has(id)) { - // Return already existing device if already known (previously detected serial number) - return this.knownDevices.get(id); - } - - return undefined; + // Return already existing device if already known (previously detected serial number) + return this.knownDevices.get(id.toString()); } public addKnownDevice(knownDevice: KnownDevice): void { - this.knownDevices.set(knownDevice.serialNo, knownDevice); + this.knownDevices.set(knownDevice.id.toString(), knownDevice); } public addDeviceSource(deviceSource: DeviceSource): void diff --git a/src/settings/settingsManager.ts b/src/settings/settingsManager.ts index efb114d6..8e8dff32 100644 --- a/src/settings/settingsManager.ts +++ b/src/settings/settingsManager.ts @@ -100,9 +100,11 @@ export default class SettingsManager } try { + const normalized = this.classToPlainSerializer.transform(this.settings); + console.log(normalized); fs.writeFileSync( this.settingsFilePath, - JSON.stringify(this.classToPlainSerializer.transform(this.settings), null, 4) + JSON.stringify(normalized, null, 4) ); this.eventEmitter.emit('settingsChanged', this.settings); this.logger.debug(`Settings saved to '${this.settingsFilePath}' due to a change`); diff --git a/src/util/createMapTransformFn.ts b/src/util/createMapTransformFn.ts index f8624a3f..983c2997 100644 --- a/src/util/createMapTransformFn.ts +++ b/src/util/createMapTransformFn.ts @@ -8,19 +8,22 @@ import { ClassConstructor, instanceToPlain, plainToInstance, TransformationType, * @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...) */ export default function createMapTransformFn(mapValueClass: ClassConstructor) { - return ({ type, value, options }: TransformFnParams): any => { + return (params: TransformFnParams): any => { + const { type, value, options } = params; const isPrimitiveClass = [String, Number, Boolean].includes(mapValueClass as any); switch (type) { case TransformationType.PLAIN_TO_CLASS: { if (value instanceof Object === false) { - return new Map(); + return new Map(); } const transformedEntries = Object.entries(value) .filter(([, v]) => { return isPrimitiveClass || typeof v === "object"; }) .map(([k, v]) => { - return [k, isPrimitiveClass ? (mapValueClass as any)(v) : plainToInstance(mapValueClass, v, options)]; + const transformedValue = isPrimitiveClass ? (mapValueClass as any)(v) : plainToInstance(mapValueClass, v, options); + console.log(options, k, transformedValue) + return [k, transformedValue]; }) as [string, T][]; return new Map(transformedEntries); } From b7b191cd1c623f9f84e10bb2ab5300458b87813a Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 22 Mar 2026 23:47:12 +0100 Subject: [PATCH 03/42] Enforce consistent-type-assertion --- eslint.config.ts | 16 +- package-lock.json | 608 ++++++++---------- package.json | 7 +- src/automation/scriptRuntime.ts | 9 +- .../automation/createScriptController.ts | 14 +- .../automation/deleteScriptController.ts | 10 +- src/controller/automation/getLogController.ts | 4 +- .../automation/getScriptController.ts | 10 +- .../automation/runScriptController.ts | 6 +- src/controller/getDeviceController.ts | 6 +- src/controller/patchDeviceController.ts | 14 +- src/device/attribute/boolDeviceAttribute.ts | 1 + src/device/attribute/floatDeviceAttribute.ts | 1 + src/device/attribute/intDeviceAttribute.ts | 1 + .../attribute/intRangeDeviceAttribute.ts | 1 + src/device/attribute/listDeviceAttribute.ts | 4 +- src/device/attribute/strDeviceAttribute.ts | 1 + src/device/device.ts | 6 +- src/device/deviceId.ts | 24 +- src/device/deviceManager.ts | 2 +- src/device/peripheralDevice.ts | 2 +- src/device/protocol/airotic/airoticDevice.ts | 2 +- .../protocol/airotic/airoticDeviceProvider.ts | 2 +- .../protocol/buttplugIo/buttplugIoDevice.ts | 2 +- .../buttplugIo/buttplugIoDeviceFactory.ts | 10 +- src/device/protocol/estim2b/estim2bDevice.ts | 2 +- .../protocol/estim2b/estim2bDeviceFactory.ts | 2 +- .../slvCtrlPlus/genericSlvCtrlPlusDevice.ts | 2 +- .../protocol/slvCtrlPlus/slvCtrlPlusDevice.ts | 2 +- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 2 +- .../slvCtrlPlus/slvCtrlProtocolLegacy.ts | 6 +- .../protocol/slvCtrlPlus/slvCtrlProtocolV1.ts | 2 +- .../virtual/audio/piperVirtualDeviceLogic.ts | 12 +- .../virtual/genericVirtualDeviceFactory.ts | 13 +- src/device/protocol/virtual/virtualDevice.ts | 8 +- src/device/protocol/zc95/zc95Device.ts | 71 +- src/device/protocol/zc95/zc95DeviceFactory.ts | 2 +- .../protocol/zc95/zc95MessageFactory.ts | 2 +- src/device/protocol/zc95/zc95Protocol.ts | 4 +- src/device/transport/bleObserver.ts | 4 +- src/device/transport/serialPortObserver.ts | 2 +- src/index.ts | 2 +- src/logging/PinoLogger.ts | 4 +- src/repository/automationScriptRepository.ts | 2 +- src/schemaValidation/JsonSchemaValidator.ts | 10 +- .../JsonSchemaValidatorFactory.ts | 13 +- src/serial/synchronousSerialPort.ts | 2 +- src/serialization/classToPlainSerializer.ts | 5 +- src/settings/knownDevice.ts | 2 +- src/settings/settings.ts | 2 +- src/settings/settingsManager.ts | 2 +- src/socket/deviceUpdateHandler.ts | 3 +- src/util/numbers.ts | 1 + src/util/objects.ts | 1 + 54 files changed, 465 insertions(+), 483 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index d4019e44..191e4426 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,5 +1,5 @@ -import tsLint from "typescript-eslint"; -import jsLint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import eslint from "@eslint/js"; import jsdoc from "eslint-plugin-jsdoc"; import preferArrowFunctions from "eslint-plugin-prefer-arrow-functions"; import globals from 'globals'; @@ -15,8 +15,8 @@ export default [ 'eslint.config.ts', ], }, - jsLint.configs.recommended, - ...tsLint.configs.recommended, + eslint.configs.recommended, + ...tseslint.configs.recommended, preferArrowFunctions.configs?.all ?? [], { languageOptions: { @@ -56,7 +56,13 @@ export default [ } } ], - "@typescript-eslint/consistent-type-assertions": "error", + "@typescript-eslint/consistent-type-assertions": [ + "error", + { + "assertionStyle": "never", + } + ], + "@typescript-eslint/no-unnecessary-type-assertion": "error", "@typescript-eslint/dot-notation": "error", "@typescript-eslint/explicit-function-return-type": "error", "@typescript-eslint/explicit-member-accessibility": [ diff --git a/package-lock.json b/package-lock.json index 0a0ebebe..b01864da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "@babel/plugin-proposal-decorators": "^7.22.7", "@babel/preset-env": "^7.14.4", "@babel/preset-typescript": "^7.22.5", + "@eslint/js": "^10.0.1", "@types/cors": "^2.8.10", "@types/express": "^5.0.6", "@types/glob": "^7.2.0", @@ -47,16 +48,16 @@ "@types/uuid": "^8.3.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", - "eslint": "^9.39.2", + "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-jsdoc": "^61.5.0", + "eslint-plugin-jsdoc": "^62.8.0", "eslint-plugin-prefer-arrow-functions": "^3.9.1", "globals": "^17.0.0", "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^10.3.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.52.0", + "typescript-eslint": "^8.57.1", "vitest": "^4.0.16", "vitest-mock-extended": "^3.1.0" }, @@ -1739,20 +1740,20 @@ } }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.78.0.tgz", - "integrity": "sha512-rQkU5u8hNAq2NVRzHnIUUvR6arbO0b6AOlvpTNS48CkiKSn/xtNfOzBK23JE4SiW89DgvU7GtxLVgV4Vn2HBAw==", + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.84.0.tgz", + "integrity": "sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.46.4", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~7.0.0" + "@typescript-eslint/types": "^8.54.0", + "comment-parser": "1.4.5", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.1.1" }, "engines": { - "node": ">=20.11.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@es-joy/resolve.exports": { @@ -2250,142 +2251,128 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@eslint/core": "^1.1.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -3532,6 +3519,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3672,17 +3666,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", - "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.53.0", - "@typescript-eslint/type-utils": "8.53.0", - "@typescript-eslint/utils": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -3695,8 +3689,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.53.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -3711,16 +3705,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", - "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.53.0", - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3" }, "engines": { @@ -3731,19 +3725,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", - "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.53.0", - "@typescript-eslint/types": "^8.53.0", + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", "debug": "^4.4.3" }, "engines": { @@ -3758,14 +3752,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", - "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3776,9 +3770,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", - "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", "dev": true, "license": "MIT", "engines": { @@ -3793,15 +3787,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", - "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0", - "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -3813,14 +3807,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", - "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", "dev": true, "license": "MIT", "engines": { @@ -3832,18 +3826,18 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", - "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.53.0", - "@typescript-eslint/tsconfig-utils": "8.53.0", - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0", + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" @@ -3859,36 +3853,49 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -3899,16 +3906,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", - "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.53.0", - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0" + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3918,19 +3925,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", - "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.57.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4115,9 +4122,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4226,13 +4233,6 @@ "node": ">=14" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -4676,16 +4676,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001764", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", @@ -4843,9 +4833,9 @@ } }, "node_modules/comment-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", - "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", + "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", "dev": true, "license": "MIT", "engines": { @@ -5489,33 +5479,30 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -5525,8 +5512,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5534,7 +5520,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -5565,69 +5551,38 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "61.7.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-61.7.1.tgz", - "integrity": "sha512-36DpldF95MlTX//n3/naULFVt8d1cV4jmSkx7ZKrE9ikkKHAgMLesuWp1SmwpVwAs5ndIM6abKd6PeOYZUgdWg==", + "version": "62.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.8.0.tgz", + "integrity": "sha512-hu3r9/6JBmPG6wTcqtYzgZAnjEG2eqRUATfkFscokESg1VDxZM21ZaMire0KjeMwfj+SXvgB4Rvh5LBuesj92w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.78.0", + "@es-joy/jsdoccomment": "~0.84.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", + "comment-parser": "1.4.5", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", - "espree": "^11.0.0", + "espree": "^11.1.0", "esquery": "^1.7.0", "html-entities": "^2.6.0", "object-deep-merge": "^2.0.0", "parse-imports-exports": "^0.2.4", - "semver": "^7.7.3", + "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", "to-valid-identifier": "^1.0.0" }, - "engines": { - "node": ">=20.11.0" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/espree": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.0.0.tgz", - "integrity": "sha512-+gMeWRrIh/NsG+3NaLeWHuyeyk70p2tbvZIWBYcqQ4/7Xvars6GYTZNhF1sIeLcc6Wb11He5ffz3hsHyXFrw5A==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.0" - }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -5655,39 +5610,41 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -5701,6 +5658,29 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5708,19 +5688,35 @@ "dev": true, "license": "MIT" }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6534,23 +6530,6 @@ "dev": true, "license": "ISC" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -7190,23 +7169,10 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsdoc-type-pratt-parser": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.0.0.tgz", - "integrity": "sha512-c7YbokssPOSHmqTbSAmTtnVgAVa/7lumWNYqomgd5KOMyPrRve2anx6lonfOsXEQacqF9FKVUj7bLg4vRSvdYA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.1.tgz", + "integrity": "sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==", "dev": true, "license": "MIT", "engines": { @@ -7365,13 +7331,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7981,19 +7940,6 @@ "node": ">=6" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse-imports-exports": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", @@ -8695,16 +8641,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rollup": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", @@ -9893,16 +9829,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz", - "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.53.0", - "@typescript-eslint/parser": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0", - "@typescript-eslint/utils": "8.53.0" + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9912,7 +9848,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, diff --git a/package.json b/package.json index 58da2215..c26f71bb 100644 --- a/package.json +++ b/package.json @@ -45,16 +45,17 @@ "@types/uuid": "^8.3.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", - "eslint": "^9.39.2", + "eslint": "^10.1.0", + "@eslint/js": "^10.0.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-jsdoc": "^61.5.0", + "eslint-plugin-jsdoc": "^62.8.0", "eslint-plugin-prefer-arrow-functions": "^3.9.1", "globals": "^17.0.0", "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^10.3.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.52.0", + "typescript-eslint": "^8.57.1", "vitest": "^4.0.16", "vitest-mock-extended": "^3.1.0" }, diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index af41bc3e..b41bf57a 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -1,4 +1,5 @@ import { NodeVM, VMScript } from 'vm2'; +import BaseError from 'modern-errors'; import Device from '../device/device.js'; import DeviceRepositoryInterface from '../repository/deviceRepositoryInterface.js'; import fs, { WriteStream } from 'fs'; @@ -110,10 +111,10 @@ export class ScriptRuntime try { this.vm.run(this.scriptCode); } catch (e: unknown) { - const msg = (e as Error).message; - console.error(`VM stdout: ${msg}`); - void this.log(msg); - this.eventEmitter.emit(AutomationEventType.consoleLog, (e as Error).toString()); + const error = BaseError.normalize(e); + console.error(`VM stdout: ${error.message}`); + void this.log(error.message); + this.eventEmitter.emit(AutomationEventType.consoleLog, error.toString()); } } diff --git a/src/controller/automation/createScriptController.ts b/src/controller/automation/createScriptController.ts index 991a9213..6bace25c 100644 --- a/src/controller/automation/createScriptController.ts +++ b/src/controller/automation/createScriptController.ts @@ -3,6 +3,10 @@ import ControllerInterface from '../controllerInterface.js'; import AutomationScriptRepositoryInterface from '../../repository/automationScriptRepositoryInterface.js'; import { isValidAutomationScriptFileName } from '../../automation/utils.js'; +type RequestParams = { fileName: string }; +type RequestBody = string; +type CreateScriptRequest = Request; + export default class CreateScriptController implements ControllerInterface { private readonly automationScriptRepository: AutomationScriptRepositoryInterface; @@ -13,7 +17,7 @@ export default class CreateScriptController implements ControllerInterface this.automationScriptRepository = automationScriptRepository; } - public execute(req: Request, res: Response): void + public execute(req: CreateScriptRequest, res: Response): void { const matchedContentType = req.is('text/plain'); @@ -24,13 +28,13 @@ export default class CreateScriptController implements ControllerInterface const { fileName } = req.params; - if (!isValidAutomationScriptFileName(fileName as string)) { - res.status(400).send(`Invalid filename: ${fileName as string}`); + if (!isValidAutomationScriptFileName(fileName)) { + res.status(400).send(`Invalid filename: ${fileName}`); return; } - this.automationScriptRepository.save(fileName as string, req.body as string); + this.automationScriptRepository.save(fileName, req.body); - res.header('Content-Type', 'text/plain').status(201).end(req.body as string); + res.header('Content-Type', 'text/plain').status(201).end(req.body); } } diff --git a/src/controller/automation/deleteScriptController.ts b/src/controller/automation/deleteScriptController.ts index 807fa08d..65e47fea 100644 --- a/src/controller/automation/deleteScriptController.ts +++ b/src/controller/automation/deleteScriptController.ts @@ -3,6 +3,8 @@ import ControllerInterface from '../controllerInterface.js'; import AutomationScriptRepositoryInterface from '../../repository/automationScriptRepositoryInterface.js'; import { isValidAutomationScriptFileName } from '../../automation/utils.js'; +type RequestParams = { fileName: string }; + export default class DeleteScriptController implements ControllerInterface { private readonly automationScriptRepository: AutomationScriptRepositoryInterface; @@ -13,16 +15,16 @@ export default class DeleteScriptController implements ControllerInterface this.automationScriptRepository = automationScriptRepository; } - public execute(req: Request, res: Response): void + public execute(req: Request, res: Response): void { const { fileName } = req.params; - if (!isValidAutomationScriptFileName(fileName as string)) { - res.status(400).send(`Invalid filename: ${fileName as string}`); + if (!isValidAutomationScriptFileName(fileName)) { + res.status(400).send(`Invalid filename: ${fileName}`); return; } - this.automationScriptRepository.delete(fileName as string); + this.automationScriptRepository.delete(fileName); res.sendStatus(204); } diff --git a/src/controller/automation/getLogController.ts b/src/controller/automation/getLogController.ts index a31c6fd6..3510a1a8 100644 --- a/src/controller/automation/getLogController.ts +++ b/src/controller/automation/getLogController.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import BaseError from 'modern-errors'; import ControllerInterface from '../controllerInterface.js'; import ScriptRuntime from '../../automation/scriptRuntime.js'; @@ -20,7 +21,8 @@ export default class GetLogController implements ControllerInterface res.header('Content-Type', 'text/plain').status(200).end(lines); } catch (e: unknown) { - res.header('Content-Type', 'text/plain').status(500).end((e as Error).message); + const error = BaseError.normalize(e); + res.header('Content-Type', 'text/plain').status(500).end(error.message); } } } diff --git a/src/controller/automation/getScriptController.ts b/src/controller/automation/getScriptController.ts index 39fb162b..f625c276 100644 --- a/src/controller/automation/getScriptController.ts +++ b/src/controller/automation/getScriptController.ts @@ -3,6 +3,8 @@ import ControllerInterface from '../controllerInterface.js'; import AutomationScriptRepositoryInterface from '../../repository/automationScriptRepositoryInterface.js'; import { isValidAutomationScriptFileName } from '../../automation/utils.js'; +type RequestParams = { fileName: string }; + export default class GetScriptController implements ControllerInterface { private readonly automationScriptRepository: AutomationScriptRepositoryInterface; @@ -13,16 +15,16 @@ export default class GetScriptController implements ControllerInterface this.automationScriptRepository = automationScriptRepository; } - public execute(req: Request, res: Response): void + public execute(req: Request, res: Response): void { const { fileName } = req.params; - if (!isValidAutomationScriptFileName(fileName as string)) { - res.status(400).send(`Invalid filename: ${fileName as string}`); + if (!isValidAutomationScriptFileName(fileName)) { + res.status(400).send(`Invalid filename: ${fileName}`); return; } - const scriptContent = this.automationScriptRepository.getByName(fileName as string); + const scriptContent = this.automationScriptRepository.getByName(fileName); if (null === scriptContent) { res.sendStatus(404); diff --git a/src/controller/automation/runScriptController.ts b/src/controller/automation/runScriptController.ts index 9a07ecbc..b96f9259 100644 --- a/src/controller/automation/runScriptController.ts +++ b/src/controller/automation/runScriptController.ts @@ -2,6 +2,8 @@ import { Request, Response } from 'express'; import ControllerInterface from '../controllerInterface.js'; import ScriptRuntime from '../../automation/scriptRuntime.js'; +type RunScriptRequest = Request; + export default class RunScriptController implements ControllerInterface { private readonly scriptRuntime: ScriptRuntime; @@ -11,7 +13,7 @@ export default class RunScriptController implements ControllerInterface this.scriptRuntime = scriptRuntime; } - public execute(req: Request, res: Response): void + public execute(req: RunScriptRequest, res: Response): void { const matchedContentType = req.is('text/plain'); @@ -20,7 +22,7 @@ export default class RunScriptController implements ControllerInterface return; } - const scriptCode = req.body as string; + const scriptCode = req.body; this.scriptRuntime.load(scriptCode); diff --git a/src/controller/getDeviceController.ts b/src/controller/getDeviceController.ts index 285ed4f8..470ecf32 100644 --- a/src/controller/getDeviceController.ts +++ b/src/controller/getDeviceController.ts @@ -4,6 +4,8 @@ import ClassToPlainSerializer from '../serialization/classToPlainSerializer.js'; import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.js'; import DeviceDiscriminator from '../serialization/discriminator/deviceDiscriminator.js'; +type GetDeviceRequest = Request<{ deviceId: string }>; + export default class GetDeviceController implements ControllerInterface { private connectedDeviceRepository: ConnectedDeviceRepository; @@ -16,10 +18,10 @@ export default class GetDeviceController implements ControllerInterface this.serializer = serializer; } - public execute(req: Request, res: Response): void + public execute(req: GetDeviceRequest, res: Response): void { const { deviceId } = req.params; - const device = this.connectedDeviceRepository.getById(deviceId as string); + const device = this.connectedDeviceRepository.getById(deviceId); if (null === device) { res.sendStatus(404); diff --git a/src/controller/patchDeviceController.ts b/src/controller/patchDeviceController.ts index 8438f78d..403a952a 100644 --- a/src/controller/patchDeviceController.ts +++ b/src/controller/patchDeviceController.ts @@ -1,9 +1,12 @@ import { Request, Response } from 'express'; +import BaseError from 'modern-errors'; import ControllerInterface from './controllerInterface.js'; import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.js'; import DeviceUpdaterInterface from '../device/updater/deviceUpdaterInterface.js'; import { DeviceData } from '../device/device.js'; +type PatchDeviceRequest = Request<{ deviceId: string }, any, DeviceData>; + export default class PatchDeviceController implements ControllerInterface { private connectedDeviceRepository: ConnectedDeviceRepository; @@ -16,10 +19,10 @@ export default class PatchDeviceController implements ControllerInterface this.deviceUpdater = deviceUpdater; } - public async execute(req: Request, res: Response): Promise + public async execute(req: PatchDeviceRequest, res: Response): Promise { const { deviceId } = req.params; - const device = this.connectedDeviceRepository.getById(deviceId as string); + const device = this.connectedDeviceRepository.getById(deviceId); if (null === device) { res.sendStatus(404); @@ -27,9 +30,10 @@ export default class PatchDeviceController implements ControllerInterface } try { - await this.deviceUpdater.update(device, req.body as DeviceData); - } catch (err: unknown) { - res.send((err as Error).message).sendStatus(500); + await this.deviceUpdater.update(device, req.body); + } catch (e: unknown) { + const error = BaseError.normalize(e); + res.send(error.message).sendStatus(500); } res.sendStatus(202); diff --git a/src/device/attribute/boolDeviceAttribute.ts b/src/device/attribute/boolDeviceAttribute.ts index 7abf6a17..398c42f2 100644 --- a/src/device/attribute/boolDeviceAttribute.ts +++ b/src/device/attribute/boolDeviceAttribute.ts @@ -23,6 +23,7 @@ export default class BoolDeviceAttribute { if (typeof value === 'string' || typeof value === 'number') { - return -1 !== this._values.findIndex(entry => entry.key === (value as IKey)); + return -1 !== this._values.findIndex(entry => entry.key === value); } return false; } diff --git a/src/device/attribute/strDeviceAttribute.ts b/src/device/attribute/strDeviceAttribute.ts index daf66378..41e7853e 100644 --- a/src/device/attribute/strDeviceAttribute.ts +++ b/src/device/attribute/strDeviceAttribute.ts @@ -23,6 +23,7 @@ export default class StrDeviceAttribute> = D extends Device ? TAttrs : DeviceAttributes; @@ -43,10 +43,10 @@ export default abstract class Device< TConfig extends AnyDeviceConfig = NoDeviceConfig > { @Expose() - protected readonly connectedSince: Date; + protected readonly deviceId: DeviceId; @Expose() - protected readonly deviceId: DeviceId; + protected readonly connectedSince: Date; @Expose() protected readonly deviceName: string; diff --git a/src/device/deviceId.ts b/src/device/deviceId.ts index 9a151c17..fc78042b 100644 --- a/src/device/deviceId.ts +++ b/src/device/deviceId.ts @@ -1,23 +1,13 @@ import { v5 as uuidv5 } from 'uuid'; -export default class DeviceId -{ - private static readonly DEVICE_NAMESPACE = '1e0758c9-799d-40b5-b2fc-63f1e66afb76'; +const DEVICE_NAMESPACE = '1e0758c9-799d-40b5-b2fc-63f1e66afb76'; +const deviceIdSymbol = Symbol(); - private readonly id: string; +export type DeviceId = string & { [deviceIdSymbol]: never } - public constructor(seed: string) - { - this.id = uuidv5(seed, DeviceId.DEVICE_NAMESPACE); - } - - public toString(): string - { - return this.id; - } - - public toJSON(): string - { - return this.id; +export const DeviceId = { + fromPath: (seed: string): DeviceId => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return uuidv5(seed, DEVICE_NAMESPACE).toString() as DeviceId; } } diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 43cf6efd..e8655909 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -5,7 +5,7 @@ import { setIntervalAsync } from '../util/async.js'; import Logger from '../logging/Logger.js'; import { AnyDeviceConfig } from './deviceConfig.js'; import { logError } from '../util/error.js'; -import DeviceId from './deviceId.js'; +import { DeviceId } from './deviceId.js'; export type DeviceInfo = { id: DeviceId; diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 70162982..b48b25e6 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -3,7 +3,7 @@ import DeviceTransport from './transport/deviceTransport.js'; import DeviceProtocol, { MessageResponse } from './protocol/deviceProtocol.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import EventEmitter from 'events'; -import DeviceId from './deviceId.js'; +import { DeviceId } from './deviceId.js'; export type InferPeripheralDeviceAttributes> = D extends PeripheralDevice ? TAttrs : DeviceAttributes; diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index d603010d..8834b9aa 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -5,7 +5,7 @@ import Device from '../../device.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { NoDeviceConfig } from '../../deviceConfig.js'; import { Peripheral } from '@stoprocent/noble'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; export type AiroticDeviceAttributes = { color: StrDeviceAttribute, diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 14565a13..4a4ac5e8 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -15,7 +15,7 @@ import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import { Peripheral } from '@stoprocent/noble'; import Settings from '../../../settings/settings.js'; import KnownDevice from '../../../settings/knownDevice.js'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; export default class AiroticDeviceProvider extends DeviceProvider { diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 3a8e09fa..8a207a70 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -7,7 +7,7 @@ import { Int } from '../../../util/numbers.js'; import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import EventEmitter from 'events'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; type ButtplugActuatorTypeKey = `${ActuatorType}-${number}`; type ButtplugSensorTypeKey = `${SensorType}-${number}`; diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index e1ed9b1b..b7bf785e 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -10,7 +10,7 @@ import DateFactory from '../../../factory/dateFactory.js'; import { Int } from '../../../util/numbers.js'; import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; export default class ButtplugIoDeviceFactory @@ -57,10 +57,10 @@ export default class ButtplugIoDeviceFactory } private static parseDeviceAttributes(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceAttributes { - const attributes = {} as ButtplugIoDeviceAttributes; + const attributes: ButtplugIoDeviceAttributes = {}; for (const item of buttplugDevice.messageAttributes.ScalarCmd ?? []) { - const attrName = `${item.ActuatorType}-${item.Index}` as ButtplugIoDeviceAttributeKey; + const attrName: ButtplugIoDeviceAttributeKey = `${item.ActuatorType}-${item.Index}`; if (item.StepCount > 2) { attributes[attrName] = IntRangeDeviceAttribute.createInitialized( @@ -81,7 +81,7 @@ export default class ButtplugIoDeviceFactory } for (const item of buttplugDevice.messageAttributes.SensorReadCmd ?? []) { - const attrName = `${item.SensorType}-${item.Index}` as ButtplugIoDeviceAttributeKey; + const attrName: ButtplugIoDeviceAttributeKey = `${item.SensorType}-${item.Index}`; // A range is defined by two numbers, if there are more or less, let's fallback // to a normal integer attribute. Not that dramatic for a sensor after all. @@ -115,7 +115,7 @@ export default class ButtplugIoDeviceFactory // we need to use the index assigned to the device by Intiface. It's the best we have. // or the name if using Intiface-engine without id persistence const nameString = buttplugDevice.name.replace(/[^a-zA-Z0-9]/g, ''); - const deviceId = new DeviceId(useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); + const deviceId = DeviceId.fromPath(useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); const knownDevice = this.settings.getKnownDeviceById(deviceId) diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index 13f44515..97e3befc 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -12,7 +12,7 @@ import PeripheralDevice from '../../peripheralDevice.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; export type EStim2bDeviceAttributes = { mode: ListDeviceAttribute, diff --git a/src/device/protocol/estim2b/estim2bDeviceFactory.ts b/src/device/protocol/estim2b/estim2bDeviceFactory.ts index 70dff585..ac0e60c9 100644 --- a/src/device/protocol/estim2b/estim2bDeviceFactory.ts +++ b/src/device/protocol/estim2b/estim2bDeviceFactory.ts @@ -13,7 +13,7 @@ import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import ListDeviceAttribute from '../../attribute/listDeviceAttribute.js'; import DeviceTransport from '../../transport/deviceTransport.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; export default class Estim2bDeviceFactory { diff --git a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts index fbbc6950..1decc1c2 100644 --- a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts @@ -6,7 +6,7 @@ import SlvCtrlProtocol from './slvCtrlProtocol.js'; import DeviceTransport from '../../transport/deviceTransport.js'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; @Exclude() export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts index 360da672..901d5d23 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts @@ -7,7 +7,7 @@ import PeripheralDevice from '../../peripheralDevice.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; export type SlvCtrlPlusDeviceAttributeKey = string; export type SlvCtrlPlusDeviceAttributes = Record; diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index 44b76773..b0fc8dea 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -11,7 +11,7 @@ import SlvCtrlProtocol, { DeviceInfo } from './slvCtrlProtocol.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; export default class SlvCtrlPlusDeviceFactory { diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts index b62f6770..33598420 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts @@ -90,7 +90,7 @@ export default class SlvCtrlProtocolLegacy extends SlvCtrlProtocol { const [status, reason] = rawResult.split(','); - const result: Result = { status: (['ok', 'error'].includes(status) ? status : 'unknown') as Result['status'] }; + const result: Result = { status: (['ok', 'error'].includes(status) ? status : 'unknown') }; if (undefined !== reason) { result.reason = reason; @@ -101,7 +101,7 @@ export default class SlvCtrlProtocolLegacy extends SlvCtrlProtocol private static parseDeviceAttributes(responseData: KeyValuePairs): SlvCtrlPlusDeviceAttributes { // attributes;connected:ro[bool],adc:rw[bool],mode:rw[118-140],levelA:rw[0-99],levelB:rw[0-99] - const attributeList = {} as SlvCtrlPlusDeviceAttributes; + const attributeList: SlvCtrlPlusDeviceAttributes = {}; for (const [attrName, attrDef] of Object.entries(responseData)) { const attr = this.createAttributeFromValue(attrName, attrDef); @@ -176,7 +176,7 @@ export default class SlvCtrlProtocolLegacy extends SlvCtrlProtocol const modifier = this.getAttributeTypeFromStr(type); - let attr = null; + let attr; if ('bool' === value) { attr = BoolDeviceAttribute.create(name, undefined, modifier); diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.ts b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.ts index 26093acb..a2a15864 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.ts @@ -34,7 +34,7 @@ export default class SlvCtrlProtocolV1 extends SlvCtrlProtocol } public getAttributes(responseData: KeyValuePairs): SlvCtrlPlusDeviceAttributes { - const attributeList = {} as SlvCtrlPlusDeviceAttributes; + const attributeList: SlvCtrlPlusDeviceAttributes = {}; for (const [attrName, attrDef] of Object.entries(responseData)) { const attr = SlvCtrlProtocolV1.createAttributeFromValue(attrName, attrDef); diff --git a/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts b/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts index 2255c264..e2643d1b 100644 --- a/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts @@ -12,6 +12,8 @@ import DeviceState from '../../../deviceState.js'; import { PiperVirtualDeviceConfig } from './piperVirtualDeviceConfig.js'; import DevNullStream from '../../../../util/devNullStream.js'; import VirtualDeviceLogic from '../virtualDeviceLogic.js'; +import { logError } from '../../../../util/error.js'; +import path from 'path'; type PiperVirtualDeviceAttributes = { text: StrDeviceAttribute; @@ -190,6 +192,14 @@ export default class PiperVirtualDeviceLogic extends VirtualDeviceLogic< const metadataFilePath = `${modelFilePath}.json`; try { + try { + await path.exists(metadataFilePath); + await fs.access(filePath); + } catch(e: unknown) { + this.logger.warn(`Could not read metadata file '${metadataFilePath}', reason: missing`); + return undefined; + } + const fileContent = await fs.promises.readFile(metadataFilePath, 'utf-8'); const json = JSON.parse(fileContent); @@ -197,7 +207,7 @@ export default class PiperVirtualDeviceLogic extends VirtualDeviceLogic< return json; } catch (e: unknown) { - this.logger.warn(`Could not read metadata file '${metadataFilePath}', reason: ${(e as Error).message}`); + logError(this.logger, `Could not read metadata file '${metadataFilePath}'`, e); return undefined; } } diff --git a/src/device/protocol/virtual/genericVirtualDeviceFactory.ts b/src/device/protocol/virtual/genericVirtualDeviceFactory.ts index f69df4dc..bf95e56f 100644 --- a/src/device/protocol/virtual/genericVirtualDeviceFactory.ts +++ b/src/device/protocol/virtual/genericVirtualDeviceFactory.ts @@ -70,17 +70,14 @@ export default class GenericVirtualDeviceFactory implements VirtualDeviceFactory } const jsonSchemaValidator = this.jsonSchemaValidatorFactory.create(factory.deviceConfigSchema); + const isConfigValid = jsonSchemaValidator.validate(knownDevice.config); - if (undefined !== jsonSchemaValidator) { - const isConfigValid = jsonSchemaValidator.validate(knownDevice.config); - - if (!isConfigValid) { - const validationErrors = jsonSchemaValidator.getValidationErrors(); - throw new Error(`Config for device is not valid: ${JSON.stringify(validationErrors, null, 2)}`); - } + if (!isConfigValid) { + const validationErrors = jsonSchemaValidator.getValidationErrors(); + throw new Error(`Config for device is not valid: ${JSON.stringify(validationErrors, null, 2)}`); } - const deviceLogic = factory.deviceLogicFactory.create(knownDevice.config as Static); + const deviceLogic = factory.deviceLogicFactory.create(knownDevice.config); const device = new VirtualDevice( '1.0.0', diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index 67eafbd6..531092c5 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -1,11 +1,12 @@ import { Exclude, Expose } from 'class-transformer'; +import BaseError from 'modern-errors'; import Device, { ExtractAttributeValue } from '../../device.js'; import DeviceState from '../../deviceState.js'; import VirtualDeviceLogic, { ExtractAttributes, ExtractConfig } from './virtualDeviceLogic.js'; import { AnyDeviceConfig } from '../../deviceConfig.js'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; @Exclude() export default class VirtualDevice< @@ -47,13 +48,14 @@ export default class VirtualDevice< try { await this.deviceLogic.refreshData(this); } catch (e: unknown) { + const error = BaseError.normalize(e); this.state = DeviceState.error; this.errorInfo = { - reason: (e as Error).message ?? 'Unknown error', + reason: error.message ?? 'Unknown error', occurredAt: new Date(), }; - throw e; + throw error; } } diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index 3b41b4e3..0e24d919 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -24,22 +24,25 @@ import DeviceTransport from '../../transport/deviceTransport.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import Logger from '../../../logging/Logger.js'; import EventEmitter from 'events'; -import DeviceId from '../../deviceId.js'; +import { DeviceId } from '../../deviceId.js'; type RequiredZc95DeviceAttributes = { activePattern: InitializedListDeviceAttribute; patternStarted: InitializedBoolDeviceAttribute; }; +type Zc95DevicePowerChannelAttributesKeyPrefix = `powerChannel`; +type Zc95DevicePowerChannelAttributesKey = `${Zc95DevicePowerChannelAttributesKeyPrefix}${1 | 2 | 3 | 4}`; + type Zc95DevicePowerChannelAttributes = { - powerChannel1: IntRangeDeviceAttribute; - powerChannel2: IntRangeDeviceAttribute; - powerChannel3: IntRangeDeviceAttribute; - powerChannel4: IntRangeDeviceAttribute; + [K in Zc95DevicePowerChannelAttributesKey]: IntRangeDeviceAttribute; } +type Zc95DevicePatternAttributesKeyPrefix = `patternAttribute`; +type Zc95DevicePatternAttributesKey = `${Zc95DevicePatternAttributesKeyPrefix}${number}`; + type Zc95DevicePatternAttributes = { - [key: `patternAttribute${number}`]: InitializedIntRangeDeviceAttribute | ListDeviceAttribute; + [key in Zc95DevicePatternAttributesKey]: InitializedIntRangeDeviceAttribute | ListDeviceAttribute; } export type Zc95DeviceAttributes = Partial & Zc95DevicePatternAttributes> @@ -112,10 +115,10 @@ export default class Zc95Device extends PeripheralDevice { - const attributeName = attributeNameMatch[0] as keyof Zc95DevicePatternAttributes; + private isPatternDetailAttribute(attributeNameMatch: { [key: string]: string } | undefined): attributeNameMatch is {0: Zc95DevicePatternAttributesKey, 1: string} { + return attributeNameMatch !== undefined + && 0 in attributeNameMatch + && 1 in attributeNameMatch + && attributeNameMatch[0].startsWith(Zc95Device.patternAttributePrefix); + } + + private async setAttributePatternDetail(attributeName: Zc95DevicePatternAttributesKey, menuItemId: number, value: number): Promise { const patternDetailAttr = await this.getAttribute(attributeName); - const menuItemId = parseInt(attributeNameMatch[1], 10); if (IntRangeDeviceAttribute.isInstance(patternDetailAttr) && patternDetailAttr.isValidValue(value)) { const message = this.msgFactory.createPatternMinMaxChange(menuItemId, value); @@ -226,23 +234,24 @@ export default class Zc95Device extends PeripheralDevice = MsgAndResponseIdentifier> implements DeviceProtocol { @@ -19,10 +20,11 @@ export default class Zc95Protocol { const deviceUpdateHandler = container.get('socket.deviceUpdateHandler'); - socket.on(WebSocketEvent.deviceUpdateReceived, (data) => deviceUpdateHandler.handle(data as DeviceUpdateData)); + socket.on(WebSocketEvent.deviceUpdateReceived, (data: DeviceUpdateData) => deviceUpdateHandler.handle(data)); }); const serializer = container.get('serializer.classToPlain'); diff --git a/src/logging/PinoLogger.ts b/src/logging/PinoLogger.ts index 8890d5e6..ce0348a0 100644 --- a/src/logging/PinoLogger.ts +++ b/src/logging/PinoLogger.ts @@ -11,8 +11,8 @@ export default class PinoLogger implements Logger public child(bindings?: ChildLoggerBindings, options?: ChildLoggerOptions): Logger { return new PinoLogger(this.pino.child( - bindings ?? {} as pino.Bindings, - undefined === options ? undefined : options as pino.ChildLoggerOptions + bindings ?? {}, + undefined === options ? undefined : options )); } diff --git a/src/repository/automationScriptRepository.ts b/src/repository/automationScriptRepository.ts index a587e0cc..8a399e1c 100644 --- a/src/repository/automationScriptRepository.ts +++ b/src/repository/automationScriptRepository.ts @@ -29,7 +29,7 @@ export default class AutomationScriptRepository implements AutomationScriptRepos try { return fs.readFileSync(`${this.location}${name}`).toString(); } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + if (typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT') { return null; } diff --git a/src/schemaValidation/JsonSchemaValidator.ts b/src/schemaValidation/JsonSchemaValidator.ts index b1b35eb0..f23dc15b 100644 --- a/src/schemaValidation/JsonSchemaValidator.ts +++ b/src/schemaValidation/JsonSchemaValidator.ts @@ -1,18 +1,18 @@ import { Ajv, ErrorObject, ValidateFunction } from 'ajv'; -import { JsonObject } from '../types.js'; +import { Static, TSchema } from '@sinclair/typebox'; -export default class JsonSchemaValidator +export default class JsonSchemaValidator { private readonly ajv: Ajv; private readonly schemaValidator: ValidateFunction; - public constructor(ajv: Ajv, schemaValidator: ValidateFunction) { + public constructor(ajv: Ajv, schema: T) { this.ajv = ajv; - this.schemaValidator = schemaValidator; + this.schemaValidator = ajv.compile(schema); } - public validate(data: JsonObject): boolean { + public validate(data: unknown): data is Static { return this.schemaValidator(data); } diff --git a/src/schemaValidation/JsonSchemaValidatorFactory.ts b/src/schemaValidation/JsonSchemaValidatorFactory.ts index 71b1a50e..6424b717 100644 --- a/src/schemaValidation/JsonSchemaValidatorFactory.ts +++ b/src/schemaValidation/JsonSchemaValidatorFactory.ts @@ -1,8 +1,7 @@ import { Ajv } from 'ajv'; import fs from 'fs'; import JsonSchemaValidator from './JsonSchemaValidator.js'; -import { JsonObject } from '../types.js'; -import type { Schema, JSONSchemaType } from 'ajv'; +import { TSchema } from '@sinclair/typebox'; export default class JsonSchemaValidatorFactory { @@ -13,12 +12,12 @@ export default class JsonSchemaValidatorFactory this.ajv = ajv; } - public create(schema: Schema | JSONSchemaType): JsonSchemaValidator { - return new JsonSchemaValidator(this.ajv, this.ajv.compile(schema)); + public create(schema: T): JsonSchemaValidator { + return new JsonSchemaValidator(this.ajv, schema); } - public createFromFile(schemaFilePath: string): JsonSchemaValidator { - const schemaData = JSON.parse(fs.readFileSync(schemaFilePath, 'utf-8')) as JsonObject; - return new JsonSchemaValidator(this.ajv, this.ajv.compile(schemaData)); + public createFromFile(schemaFilePath: string): JsonSchemaValidator { + const schemaData: T = JSON.parse(fs.readFileSync(schemaFilePath, 'utf-8')); + return new JsonSchemaValidator(this.ajv, schemaData); } } diff --git a/src/serial/synchronousSerialPort.ts b/src/serial/synchronousSerialPort.ts index fa75da86..0f56d6f7 100644 --- a/src/serial/synchronousSerialPort.ts +++ b/src/serial/synchronousSerialPort.ts @@ -120,7 +120,7 @@ export default class SynchronousSerialPort removeListeners(); - throw new Error(reason); + throw new Error(reason, { cause: e }); } } diff --git a/src/serialization/classToPlainSerializer.ts b/src/serialization/classToPlainSerializer.ts index 59e5886b..e3087578 100644 --- a/src/serialization/classToPlainSerializer.ts +++ b/src/serialization/classToPlainSerializer.ts @@ -1,4 +1,5 @@ import { instanceToPlain, ClassTransformOptions, TypeOptions } from 'class-transformer'; +import { JsonValue } from '../types.js'; export default class ClassToPlainSerializer { @@ -9,7 +10,7 @@ export default class ClassToPlainSerializer this.options = options; } - public transform(object: T, typeOptions: TypeOptions|null = null): V + public transform(object: T, typeOptions: TypeOptions|null = null): JsonValue { const result = instanceToPlain(object, this.options); @@ -23,6 +24,6 @@ export default class ClassToPlainSerializer result[typeOptions.discriminator.property] = discriminatorValue.name; } - return result as V; + return result; } } diff --git a/src/settings/knownDevice.ts b/src/settings/knownDevice.ts index aafba1cd..43eda19d 100644 --- a/src/settings/knownDevice.ts +++ b/src/settings/knownDevice.ts @@ -1,6 +1,6 @@ import { Exclude, Expose, Type } from 'class-transformer'; import { JsonObject } from '../types.js'; -import DeviceId from '../device/deviceId.js'; +import { DeviceId } from '../device/deviceId.js'; @Exclude() export default class KnownDevice diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 48d5934c..cebdb196 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -2,7 +2,7 @@ import { Exclude, Expose, Transform } from 'class-transformer'; import KnownDevice from './knownDevice.js'; import createMapTransformFn from '../util/createMapTransformFn.js'; import DeviceSource from './deviceSource.js'; -import DeviceId from '../device/deviceId.js'; +import { DeviceId } from '../device/deviceId.js'; @Exclude() export default class Settings diff --git a/src/settings/settingsManager.ts b/src/settings/settingsManager.ts index 8e8dff32..c8dbf23e 100644 --- a/src/settings/settingsManager.ts +++ b/src/settings/settingsManager.ts @@ -58,7 +58,7 @@ export default class SettingsManager this.settings = SettingsManager.getDefaultSettings(); this.save(); } else { - const plainJsonSettings = JSON.parse(fs.readFileSync(this.settingsFilePath, 'utf8')) as JsonObject; + const plainJsonSettings: JsonObject = JSON.parse(fs.readFileSync(this.settingsFilePath, 'utf8')); const valid = this.settingsSchemaValidator.validate(plainJsonSettings); if (!valid) { diff --git a/src/socket/deviceUpdateHandler.ts b/src/socket/deviceUpdateHandler.ts index 073c4bdd..ba316983 100644 --- a/src/socket/deviceUpdateHandler.ts +++ b/src/socket/deviceUpdateHandler.ts @@ -2,6 +2,7 @@ import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.j import DeviceUpdaterInterface from '../device/updater/deviceUpdaterInterface.js'; import { DeviceUpdateData } from './types.js'; import Logger from '../logging/Logger.js'; +import { logError } from '../util/error.js'; export default class DeviceUpdateHandler { @@ -32,7 +33,7 @@ export default class DeviceUpdateHandler try { await this.deviceUpdater.update(device, data.data); } catch (err: unknown) { - this.logger.error((err as Error).message, err); + logError(this.logger, `Error while updating device with id ${deviceId}`, err); } } } diff --git a/src/util/numbers.ts b/src/util/numbers.ts index 994a54ca..363891ea 100644 --- a/src/util/numbers.ts +++ b/src/util/numbers.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ export type Int = number & { __integer__: never }; export const Int = { diff --git a/src/util/objects.ts b/src/util/objects.ts index 12205139..999bbf15 100644 --- a/src/util/objects.ts +++ b/src/util/objects.ts @@ -1,5 +1,6 @@ type StringKey = Extract; export const getTypedKeys = (obj: T): (StringKey)[] => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return Object.keys(obj) as StringKey[]; } From 660292f70c1df6f08749f34f855b019d94e4f419 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 23 Mar 2026 22:39:06 +0100 Subject: [PATCH 04/42] still wip --- .../settings/putSettingsController.ts | 12 ++++--- .../protocol/buttplugIo/buttplugIoDevice.ts | 12 ++++++- src/device/protocol/estim2b/estim2bDevice.ts | 2 +- .../slvCtrlPlus/slvCtrlProtocolLegacy.ts | 2 +- .../virtual/audio/piperVirtualDeviceLogic.ts | 18 ++++------- src/settings/settings.ts | 32 +++++++++++++++++++ src/settings/settingsManager.ts | 9 +++--- 7 files changed, 62 insertions(+), 25 deletions(-) diff --git a/src/controller/settings/putSettingsController.ts b/src/controller/settings/putSettingsController.ts index c576fdbc..aa745f9b 100644 --- a/src/controller/settings/putSettingsController.ts +++ b/src/controller/settings/putSettingsController.ts @@ -1,12 +1,14 @@ import { Request, Response } from 'express'; import ControllerInterface from '../controllerInterface.js'; import SettingsManager from '../../settings/settingsManager.js'; -import Settings from '../../settings/settings.js'; +import Settings, { SettingsSchema } from '../../settings/settings.js'; import JsonSchemaValidator from '../../schemaValidation/JsonSchemaValidator.js'; import PlainToClassSerializer from '../../serialization/plainToClassSerializer.js'; import ClassToPlainSerializer from '../../serialization/classToPlainSerializer.js'; import { JsonObject } from '../../types.js'; +type PutSettingsRequest = Request; + export default class PutSettingsController implements ControllerInterface { private settingsManager: SettingsManager; @@ -15,13 +17,13 @@ export default class PutSettingsController implements ControllerInterface private classToPlainSerializer: ClassToPlainSerializer; - private settingsSchemaValidator: JsonSchemaValidator; + private settingsSchemaValidator: JsonSchemaValidator; public constructor( settingsManager: SettingsManager, classToPlainSerializer: ClassToPlainSerializer, plainToClassSerializer: PlainToClassSerializer, - settingsSchemaValidator: JsonSchemaValidator + settingsSchemaValidator: JsonSchemaValidator ) { this.settingsManager = settingsManager; this.settingsSchemaValidator = settingsSchemaValidator; @@ -29,9 +31,9 @@ export default class PutSettingsController implements ControllerInterface this.classToPlainSerializer = classToPlainSerializer; } - public execute(req: Request, res: Response): void + public execute(req: PutSettingsRequest, res: Response): void { - const valid = this.settingsSchemaValidator.validate(req.body as JsonObject); + const valid = this.settingsSchemaValidator.validate(req.body); if (!valid) { const validationErrors = this.settingsSchemaValidator.getValidationErrors(); diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 8a207a70..d0444d62 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -80,13 +80,23 @@ export default class ButtplugIoDevice extends Device const [actuatorType, index] = attributeName.split('-'); - await this.send(actuatorType as ActuatorType, parseInt(index, 10), valueToSend); + if (!this.isActuatorTypeKey(actuatorType)) { + throw new Error(`Attribute with name '${attributeName}' does not correspond to a valid actuator type key`); + } + + await this.send(actuatorType, parseInt(index, 10), valueToSend); this.attributes[`${attributeName}`].value = value; return value; } + private isActuatorTypeKey(key: string): key is ActuatorType { + const actuatorValueSet: (ActuatorType|string)[] = Object.values(ActuatorType); + + return actuatorValueSet.includes(key); + } + protected async send(command: ActuatorType, index: number, value: number): Promise { return await this.buttplugClientDevice.scalar({ 'ActuatorType': command, diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index 97e3befc..1563c760 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -131,7 +131,7 @@ export default class EStim2bDevice extends PeripheralDevice diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts index 33598420..cfc8f4d8 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts @@ -90,7 +90,7 @@ export default class SlvCtrlProtocolLegacy extends SlvCtrlProtocol { const [status, reason] = rawResult.split(','); - const result: Result = { status: (['ok', 'error'].includes(status) ? status : 'unknown') }; + const result: Result = { status: (status === 'ok' || status === 'error') ? status : 'unknown' }; if (undefined !== reason) { result.reason = reason; diff --git a/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts b/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts index e2643d1b..14d0cad0 100644 --- a/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts @@ -12,8 +12,6 @@ import DeviceState from '../../../deviceState.js'; import { PiperVirtualDeviceConfig } from './piperVirtualDeviceConfig.js'; import DevNullStream from '../../../../util/devNullStream.js'; import VirtualDeviceLogic from '../virtualDeviceLogic.js'; -import { logError } from '../../../../util/error.js'; -import path from 'path'; type PiperVirtualDeviceAttributes = { text: StrDeviceAttribute; @@ -192,14 +190,6 @@ export default class PiperVirtualDeviceLogic extends VirtualDeviceLogic< const metadataFilePath = `${modelFilePath}.json`; try { - try { - await path.exists(metadataFilePath); - await fs.access(filePath); - } catch(e: unknown) { - this.logger.warn(`Could not read metadata file '${metadataFilePath}', reason: missing`); - return undefined; - } - const fileContent = await fs.promises.readFile(metadataFilePath, 'utf-8'); const json = JSON.parse(fileContent); @@ -207,8 +197,12 @@ export default class PiperVirtualDeviceLogic extends VirtualDeviceLogic< return json; } catch (e: unknown) { - logError(this.logger, `Could not read metadata file '${metadataFilePath}'`, e); - return undefined; + if (typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT') { + this.logger.warn(`Could not read metadata file '${metadataFilePath}'`); + return undefined; + } + + throw e; } } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index cebdb196..7b91f7a3 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -3,6 +3,38 @@ import KnownDevice from './knownDevice.js'; import createMapTransformFn from '../util/createMapTransformFn.js'; import DeviceSource from './deviceSource.js'; import { DeviceId } from '../device/deviceId.js'; +import { Type } from '@sinclair/typebox'; + +export const SettingsSchema = Type.Object({ + knownDevices: Type.Record( + Type.String(), + Type.Object({ + id: Type.String({ format: 'uuid' }), + serialNo: Type.Optional(Type.String()), + name: Type.String(), + type: Type.String(), + source: Type.String(), + config: Type.Optional(Type.Object({}, { additionalProperties: true })) + }, { + additionalProperties: false, + required: ['id', 'name', 'type', 'source'] + }) + ), + deviceSources: Type.Record( + Type.String(), + Type.Object({ + id: Type.String({ format: 'uuid' }), + type: Type.String(), + config: Type.Object({}, { additionalProperties: true }) + }, { + additionalProperties: false, + required: ['id', 'type', 'config'] + }) + ) +}, { + additionalProperties: false, + required: ['knownDevices', 'deviceSources'] +}); @Exclude() export default class Settings diff --git a/src/settings/settingsManager.ts b/src/settings/settingsManager.ts index c8dbf23e..ee05964e 100644 --- a/src/settings/settingsManager.ts +++ b/src/settings/settingsManager.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import PlainToClassSerializer from '../serialization/plainToClassSerializer.js'; import ClassToPlainSerializer from '../serialization/classToPlainSerializer.js'; import Settings from './settings.js'; +import type { SettingsSchema } from './settings.js'; import onChange from 'on-change'; import DeviceSource from './deviceSource.js'; import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; @@ -31,13 +32,13 @@ export default class SettingsManager private readonly logger: Logger; - private readonly settingsSchemaValidator: JsonSchemaValidator; + private readonly settingsSchemaValidator: JsonSchemaValidator; public constructor( settingsFilePath: string, plainToClassSerializer: PlainToClassSerializer, classToPlainSerializer: ClassToPlainSerializer, - settingsSchemaValidator: JsonSchemaValidator, + settingsSchemaValidator: JsonSchemaValidator, eventEmitter: EventEmitter, logger: Logger ) { @@ -59,9 +60,8 @@ export default class SettingsManager this.save(); } else { const plainJsonSettings: JsonObject = JSON.parse(fs.readFileSync(this.settingsFilePath, 'utf8')); - const valid = this.settingsSchemaValidator.validate(plainJsonSettings); - if (!valid) { + if (!this.settingsSchemaValidator.validate(plainJsonSettings)) { const validationErrors = this.settingsSchemaValidator.getValidationErrorsAsText(); const invalidFormatMsg = `Settings are not in a valid format: ${validationErrors}`; this.logger.error(invalidFormatMsg); @@ -101,7 +101,6 @@ export default class SettingsManager try { const normalized = this.classToPlainSerializer.transform(this.settings); - console.log(normalized); fs.writeFileSync( this.settingsFilePath, JSON.stringify(normalized, null, 4) From 25e63154e0dedbd82ca92f4825947770e8a48011 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 24 Mar 2026 08:22:25 +0100 Subject: [PATCH 05/42] No unecessary type assertions anymore and it work --- src/device/device.ts | 10 ++- .../protocol/buttplugIo/buttplugIoDevice.ts | 7 +- src/device/protocol/estim2b/estim2bDevice.ts | 9 +-- .../slvCtrlPlus/genericSlvCtrlPlusDevice.ts | 9 +-- src/device/protocol/virtual/virtualDevice.ts | 9 +-- src/device/protocol/zc95/zc95Device.ts | 79 ++++++++++++------- 6 files changed, 70 insertions(+), 53 deletions(-) diff --git a/src/device/device.ts b/src/device/device.ts index 48994c2e..bba23ffe 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -14,7 +14,7 @@ export type InferDeviceConfig export type DeviceAttributes = Record; -export type ExtractAttributeValue = A extends DeviceAttribute ? V : never; +type ExtractAttributeValue = A extends DeviceAttribute ? V : never; type AttributeKey = T extends { value: any } ? T : never; export type DeviceData = { @@ -37,6 +37,9 @@ export type DeviceEventMap> = { [DeviceEvent.deviceDisconnected]: [device: TDevice]; } +export type AttributeValue = + ExtractAttributeValue; + @Exclude() export default abstract class Device< TAttributes extends DeviceAttributes = DeviceAttributes, @@ -148,9 +151,8 @@ export default abstract class Device< } public abstract setAttribute< - K extends keyof TAttributes & string, - V extends ExtractAttributeValue - >(attributeName: K, value: V): Promise; + K extends keyof TAttributes & string + >(attributeName: K, value: AttributeValue): Promise>; public on(event: K, listener: (...args: DeviceEventMap[K]) => void): void { diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index d0444d62..dea008e7 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -1,6 +1,6 @@ import { Exclude, Expose } from 'class-transformer'; import { ActuatorType, ButtplugClientDevice, SensorType } from 'buttplug'; -import Device, { ExtractAttributeValue } from '../../device.js'; +import Device, { AttributeValue } from '../../device.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import { Int } from '../../../util/numbers.js'; @@ -49,9 +49,8 @@ export default class ButtplugIoDevice extends Device } public async setAttribute< - K extends keyof ButtplugIoDeviceAttributes & string, - V extends ExtractAttributeValue - >(attributeName: K, value: V): Promise { + K extends keyof ButtplugIoDeviceAttributes & string + >(attributeName: K, value: AttributeValue): Promise> { const attribute = this.attributes[attributeName]; if (undefined === attribute) { diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index 1563c760..f7a4d303 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -1,4 +1,4 @@ -import { ExtractAttributeValue } from '../../device.js'; +import { AttributeValue } from '../../device.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; import EStim2bProtocol, { Estim2bCommand, EStim2bMode, EStim2bStatus } from './estim2bProtocol.js'; import { Exclude, Expose } from 'class-transformer'; @@ -93,9 +93,8 @@ export default class EStim2bDevice extends PeripheralDevice - >(attributeName: K, value: V): Promise { + K extends keyof EStim2bDeviceAttributes & string + >(attributeName: K, value: AttributeValue): Promise> { const attribute = this.attributes[attributeName] if (undefined === attribute) { @@ -131,7 +130,7 @@ export default class EStim2bDevice extends PeripheralDevice diff --git a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts index 1decc1c2..4d17eb87 100644 --- a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts @@ -1,7 +1,7 @@ import { Exclude, Expose } from 'class-transformer'; import SlvCtrlPlusDevice, { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; import DeviceState from '../../deviceState.js'; -import { ExtractAttributeValue } from '../../device.js'; +import { AttributeValue } from '../../device.js'; import SlvCtrlProtocol from './slvCtrlProtocol.js'; import DeviceTransport from '../../transport/deviceTransport.js'; import EventEmitter from 'events'; @@ -63,9 +63,8 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice } public async setAttribute< - K extends keyof SlvCtrlPlusDeviceAttributes, - V extends ExtractAttributeValue - >(attributeName: K, value: V): Promise { + K extends keyof SlvCtrlPlusDeviceAttributes + >(attributeName: K, value: AttributeValue): Promise> { const attr = this.attributes[attributeName]; if (undefined === attr) { @@ -92,7 +91,7 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice attr.value = attr.fromString(response.data.value); } - return attr.value as V; + return attr.value; } finally { this.state = DeviceState.ready; } diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index 531092c5..b9e30e7f 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -1,6 +1,6 @@ import { Exclude, Expose } from 'class-transformer'; import BaseError from 'modern-errors'; -import Device, { ExtractAttributeValue } from '../../device.js'; +import Device, { AttributeValue } from '../../device.js'; import DeviceState from '../../deviceState.js'; import VirtualDeviceLogic, { ExtractAttributes, ExtractConfig } from './virtualDeviceLogic.js'; import { AnyDeviceConfig } from '../../deviceConfig.js'; @@ -64,10 +64,9 @@ export default class VirtualDevice< } public async setAttribute< - K extends keyof ExtractAttributes, - V extends ExtractAttributeValue[K]> - >(attributeName: K, value: V): Promise { - return new Promise((resolve, reject) => { + K extends keyof ExtractAttributes & string + >(attributeName: K, value: AttributeValue): Promise> { + return new Promise>((resolve, reject) => { this.state = DeviceState.busy; const attribute = this.attributes[attributeName]; diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index 0e24d919..53998962 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -1,5 +1,5 @@ import { Exclude, Expose } from 'class-transformer'; -import { ExtractAttributeValue } from '../../device.js'; +import { AttributeValue } from '../../device.js'; import Zc95MessageFactory, { MenuItem, MinMaxMenuItem, @@ -48,6 +48,8 @@ type Zc95DevicePatternAttributes = { export type Zc95DeviceAttributes = Partial & Zc95DevicePatternAttributes> & Required; +type AnyZc95DeviceAttribute = Zc95DeviceAttributes[keyof Zc95DeviceAttributes]; + @Exclude() export default class Zc95Device extends PeripheralDevice { @@ -94,31 +96,34 @@ export default class Zc95Device extends PeripheralDevice - >(attributeName: K, value: V): Promise { - if (attributeName === 'activePattern' && this.attributes.activePattern.isValidValue(value)) { + K extends keyof Zc95DeviceAttributes & string + >(attributeName: K, value: AttributeValue): Promise> { + const attribute = this.attributes[attributeName]; + + if (undefined === attribute) { + throw new Error(`Attribute with name '${attributeName}' does not exist for this device`) + } + + if (this.isActivePatternAttribute(attribute) && attribute.isValidValue(value)) { await this.setAttributeActivePattern(value); this.updateLastRefresh(); - return value; + return attribute.value; } - if (attributeName === 'patternStarted' && this.attributes.patternStarted.isValidValue(value)) { + if (this.isPatternStartedAttribute(attribute) && attribute.isValidValue(value)) { await this.setAttributePatternStarted(value); this.updateLastRefresh(); - return value; + return attribute.value; } - if (this.isPowerChannelAttribute(attributeName) && typeof value === 'number') { - await this.setAttributePowerChannel(attributeName, value); + if (this.isPowerChannelAttribute(attribute) && attribute.isValidValue(value)) { + await this.setAttributePowerChannel(attribute, value); this.updateLastRefresh(); return value; } - const patternDetailAttrMatch = Zc95Device.patternDetailAttributeRegex.exec(attributeName)?.groups; - - if (this.isPatternDetailAttribute(patternDetailAttrMatch) && typeof value === 'number') { - await this.setAttributePatternDetail(patternDetailAttrMatch[0], parseInt(patternDetailAttrMatch[1], 10), value); + if (this.isPatternDetailAttribute(attribute) && attribute.isValidValue(value)) { + await this.setAttributePatternDetail(attribute, value); this.updateLastRefresh(); return value; } @@ -128,15 +133,13 @@ export default class Zc95Device extends PeripheralDevice { - private async setAttributePatternDetail(attributeName: Zc95DevicePatternAttributesKey, menuItemId: number, value: number): Promise { - const patternDetailAttr = await this.getAttribute(attributeName); + const menuItemId = parseInt(patternDetailAttr.name.slice(Zc95Device.patternAttributePrefix.length), 10); + + if (!isNaN(menuItemId)) { + throw new Error(`Attribute name '${patternDetailAttr.name}' does not contain a valid menu item id`); + } if (IntRangeDeviceAttribute.isInstance(patternDetailAttr) && patternDetailAttr.isValidValue(value)) { const message = this.msgFactory.createPatternMinMaxChange(menuItemId, value); @@ -148,27 +151,27 @@ export default class Zc95Device extends PeripheralDevice { if (!this.allPowerChannelValuesDefined(this.attributes)) { return; } - const tmpData = { + const tmpData: { [K in keyof Zc95DevicePowerChannelAttributes]-?: InitializedIntRangeDeviceAttribute['value'] } = { powerChannel1: this.attributes.powerChannel1.value, powerChannel2: this.attributes.powerChannel2.value, powerChannel3: this.attributes.powerChannel3.value, powerChannel4: this.attributes.powerChannel4.value, }; - tmpData[attributeName] = Int.from(value); + tmpData[attribute.name] = Int.from(value); const message = this.msgFactory.createSetPower( tmpData.powerChannel1 * 10, @@ -179,7 +182,7 @@ export default class Zc95Device extends PeripheralDevice): attrs is { @@ -336,9 +339,25 @@ export default class Zc95Device extends PeripheralDevice Date: Tue, 24 Mar 2026 08:24:42 +0100 Subject: [PATCH 06/42] Remove unused regex --- src/device/protocol/zc95/zc95Device.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index 53998962..d0cfbb1f 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -57,10 +57,6 @@ export default class Zc95Device extends PeripheralDevice Date: Wed, 25 Mar 2026 08:08:19 +0100 Subject: [PATCH 07/42] Clean typing of attributes now --- src/controller/getDevicesController.ts | 6 +--- src/device/device.ts | 28 ++++++++++++------- .../protocol/buttplugIo/buttplugIoDevice.ts | 6 ++-- src/device/protocol/estim2b/estim2bDevice.ts | 6 ++-- .../slvCtrlPlus/genericSlvCtrlPlusDevice.ts | 6 ++-- src/device/protocol/virtual/virtualDevice.ts | 8 +++--- src/device/protocol/zc95/zc95Device.ts | 23 +++++++-------- 7 files changed, 44 insertions(+), 39 deletions(-) diff --git a/src/controller/getDevicesController.ts b/src/controller/getDevicesController.ts index 7081af67..d17d8092 100644 --- a/src/controller/getDevicesController.ts +++ b/src/controller/getDevicesController.ts @@ -20,10 +20,6 @@ export default class GetDevicesController implements ControllerInterface { const list = new DeviceList(this.connectedDeviceRepository.getAll()); - const plain = this.serializer.transform(list); - - //console.log(plain); - - res.json(plain); + res.json(this.serializer.transform(list)); } } diff --git a/src/device/device.ts b/src/device/device.ts index bba23ffe..74454778 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -14,12 +14,17 @@ export type InferDeviceConfig export type DeviceAttributes = Record; -type ExtractAttributeValue = A extends DeviceAttribute ? V : never; -type AttributeKey = T extends { value: any } ? T : never; +type InferAttributeValue = A extends DeviceAttribute ? V : never; +export type AttributeKeyOf = keyof A & string; +export type AttributeValueOf> = + InferAttributeValue; + +export type DeviceAttributeOf = { + [K in AttributeKeyOf]: T[K] & { name: K } +}[AttributeKeyOf]; export type DeviceData = { - [K in keyof T as AttributeKey extends never ? never : K]: - ExtractAttributeValue; + [K in AttributeKeyOf]: AttributeValueOf; }; export type DeviceError = { @@ -37,9 +42,6 @@ export type DeviceEventMap> = { [DeviceEvent.deviceDisconnected]: [device: TDevice]; } -export type AttributeValue = - ExtractAttributeValue; - @Exclude() export default abstract class Device< TAttributes extends DeviceAttributes = DeviceAttributes, @@ -146,13 +148,13 @@ export default abstract class Device< * @returns attribute value or undefined if attribute is not found. And attribute potentially cannot be found * if the generic attribute type of this class happens to be a/wrapped in a Partial */ - public getAttribute(key: K): Promise { + public getAttribute>(key: K): Promise { return Promise.resolve(this.attributes[key]); } public abstract setAttribute< - K extends keyof TAttributes & string - >(attributeName: K, value: AttributeValue): Promise>; + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValueOf): Promise>; public on(event: K, listener: (...args: DeviceEventMap[K]) => void): void { @@ -188,4 +190,10 @@ export default abstract class Device< { return this.eventEmitter.emit(eventName, ...args); } + + protected isAttributePresent( + attr: TAttributes[keyof TAttributes] + ): attr is DeviceAttributeOf { + return attr !== null && typeof attr === 'object' && 'name' in attr && Object.keys(this.attributes).includes(attr.name); + } } diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index dea008e7..d38ac7d2 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -1,6 +1,6 @@ import { Exclude, Expose } from 'class-transformer'; import { ActuatorType, ButtplugClientDevice, SensorType } from 'buttplug'; -import Device, { AttributeValue } from '../../device.js'; +import Device, { AttributeKeyOf, AttributeValueOf } from '../../device.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import { Int } from '../../../util/numbers.js'; @@ -49,8 +49,8 @@ export default class ButtplugIoDevice extends Device } public async setAttribute< - K extends keyof ButtplugIoDeviceAttributes & string - >(attributeName: K, value: AttributeValue): Promise> { + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValueOf): Promise> { const attribute = this.attributes[attributeName]; if (undefined === attribute) { diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index f7a4d303..79efa15d 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -1,4 +1,4 @@ -import { AttributeValue } from '../../device.js'; +import { AttributeKeyOf, AttributeValueOf } from '../../device.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; import EStim2bProtocol, { Estim2bCommand, EStim2bMode, EStim2bStatus } from './estim2bProtocol.js'; import { Exclude, Expose } from 'class-transformer'; @@ -93,8 +93,8 @@ export default class EStim2bDevice extends PeripheralDevice(attributeName: K, value: AttributeValue): Promise> { + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValueOf): Promise> { const attribute = this.attributes[attributeName] if (undefined === attribute) { diff --git a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts index 4d17eb87..5001657f 100644 --- a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts @@ -1,7 +1,7 @@ import { Exclude, Expose } from 'class-transformer'; import SlvCtrlPlusDevice, { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; import DeviceState from '../../deviceState.js'; -import { AttributeValue } from '../../device.js'; +import { AttributeKeyOf, AttributeValueOf } from '../../device.js'; import SlvCtrlProtocol from './slvCtrlProtocol.js'; import DeviceTransport from '../../transport/deviceTransport.js'; import EventEmitter from 'events'; @@ -63,8 +63,8 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice } public async setAttribute< - K extends keyof SlvCtrlPlusDeviceAttributes - >(attributeName: K, value: AttributeValue): Promise> { + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValueOf): Promise> { const attr = this.attributes[attributeName]; if (undefined === attr) { diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index b9e30e7f..475e0aa8 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -1,6 +1,6 @@ import { Exclude, Expose } from 'class-transformer'; import BaseError from 'modern-errors'; -import Device, { AttributeValue } from '../../device.js'; +import Device, { AttributeKeyOf, AttributeValueOf } from '../../device.js'; import DeviceState from '../../deviceState.js'; import VirtualDeviceLogic, { ExtractAttributes, ExtractConfig } from './virtualDeviceLogic.js'; import { AnyDeviceConfig } from '../../deviceConfig.js'; @@ -64,9 +64,9 @@ export default class VirtualDevice< } public async setAttribute< - K extends keyof ExtractAttributes & string - >(attributeName: K, value: AttributeValue): Promise> { - return new Promise>((resolve, reject) => { + K extends AttributeKeyOf> + >(attributeName: K, value: AttributeValueOf): Promise> { + return new Promise>((resolve, reject) => { this.state = DeviceState.busy; const attribute = this.attributes[attributeName]; diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index d0cfbb1f..d9271f59 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -1,5 +1,5 @@ import { Exclude, Expose } from 'class-transformer'; -import { AttributeValue } from '../../device.js'; +import { AttributeKeyOf, AttributeValueOf, DeviceAttributeOf } from '../../device.js'; import Zc95MessageFactory, { MenuItem, MinMaxMenuItem, @@ -48,7 +48,8 @@ type Zc95DevicePatternAttributes = { export type Zc95DeviceAttributes = Partial & Zc95DevicePatternAttributes> & Required; -type AnyZc95DeviceAttribute = Zc95DeviceAttributes[keyof Zc95DeviceAttributes]; + +type AnyZc95DeviceAttribute = DeviceAttributeOf; @Exclude() export default class Zc95Device extends PeripheralDevice @@ -92,11 +93,11 @@ export default class Zc95Device extends PeripheralDevice(attributeName: K, value: AttributeValue): Promise> { + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValueOf): Promise> { const attribute = this.attributes[attributeName]; - if (undefined === attribute) { + if (!this.isAttributePresent(attribute)) { throw new Error(`Attribute with name '${attributeName}' does not exist for this device`) } @@ -129,7 +130,7 @@ export default class Zc95Device extends PeripheralDevice { + private async setAttributePatternDetail(patternDetailAttr: DeviceAttributeOf, value: number): Promise { const menuItemId = parseInt(patternDetailAttr.name.slice(Zc95Device.patternAttributePrefix.length), 10); @@ -153,7 +154,7 @@ export default class Zc95Device extends PeripheralDevice, value: number ): Promise { if (!this.allPowerChannelValuesDefined(this.attributes)) { @@ -336,21 +337,21 @@ export default class Zc95Device extends PeripheralDevice { return attribute !== undefined && attribute.name.startsWith(Zc95Device.powerChannelAttributePrefix) && ['1', '2', '3', '4'].includes(attribute.name.slice(Zc95Device.powerChannelAttributePrefix.length)); } - private isPatternStartedAttribute(attribute: AnyZc95DeviceAttribute): attribute is Zc95DeviceAttributes['patternStarted'] { + private isPatternStartedAttribute(attribute: AnyZc95DeviceAttribute): attribute is Zc95DeviceAttributes['patternStarted'] & { name: 'patternStarted' } { return attribute?.name === 'patternStarted'; } - private isActivePatternAttribute(attribute: AnyZc95DeviceAttribute): attribute is Zc95DeviceAttributes['activePattern'] { + private isActivePatternAttribute(attribute: AnyZc95DeviceAttribute): attribute is Zc95DeviceAttributes['activePattern'] & { name: 'activePattern' } { return attribute?.name === 'activePattern'; } - private isPatternDetailAttribute(attribute: AnyZc95DeviceAttribute): attribute is Zc95DeviceAttributes[keyof Zc95DevicePatternAttributes] { + private isPatternDetailAttribute(attribute: AnyZc95DeviceAttribute): attribute is DeviceAttributeOf { return attribute !== undefined && attribute.name.startsWith(Zc95Device.patternAttributePrefix) && !isNaN(parseInt(attribute.name.slice(Zc95Device.patternAttributePrefix.length), 10)); From 35186d6e70a5190ef2ab12868425d98e534a2ccb Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 25 Mar 2026 22:31:46 +0100 Subject: [PATCH 08/42] Simplify typing a lot --- src/controller/controllerInterface.ts | 4 ++-- src/device/deviceManager.ts | 8 ++++---- src/device/peripheralDevice.ts | 4 ++-- src/device/protocol/airotic/airoticDevice.ts | 5 ++--- .../protocol/airotic/airoticDeviceProvider.ts | 5 +++-- .../buttplugIoWebsocketDeviceProvider.ts | 2 +- .../protocol/virtual/virtualDeviceProvider.ts | 2 +- src/device/provider/deviceProvider.ts | 8 +------- src/device/provider/deviceProviderFactory.ts | 5 ++--- src/device/provider/deviceProviderLoader.ts | 4 ++-- .../provider/genericDeviceProviderFactory.ts | 12 ++++++------ src/device/provider/serialDeviceProvider.ts | 8 ++++---- src/device/transport/serialPortObserver.ts | 2 +- src/serviceMap.ts | 19 ++++++++++++------- src/serviceProvider/deviceServiceProvider.ts | 3 ++- .../settingsServiceProvider.ts | 8 ++------ src/settings/knownDevice.ts | 3 +-- src/util/expressUtils.ts | 8 +++++--- 18 files changed, 53 insertions(+), 57 deletions(-) diff --git a/src/controller/controllerInterface.ts b/src/controller/controllerInterface.ts index 316964ba..ee1df132 100644 --- a/src/controller/controllerInterface.ts +++ b/src/controller/controllerInterface.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; -export default interface ControllerInterface +export default interface ControllerInterface { - execute(req: Request, res: Response): void | Promise; + execute(req: Req, res: Res): void | Promise; } diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index e8655909..a7dbc450 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -37,7 +37,7 @@ export default class DeviceManager private readonly detectedDeviceAcquireQueue: Map void }[]> = new Map(); - private readonly connectedDevices: Map; + private readonly connectedDevices: Map>; public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { this.eventEmitter = eventEmitter; @@ -114,8 +114,8 @@ export default class DeviceManager { this.connectedDevices.set(device.getDeviceId.toString(), device); - device.on(DeviceEvent.deviceRefreshed, (d: Device) => this.refreshDevice(d)); - device.on(DeviceEvent.deviceDisconnected, (d: Device) => this.removeDevice(d)); + device.on(DeviceEvent.deviceRefreshed, (d: Device) => this.refreshDevice(d)); + device.on(DeviceEvent.deviceDisconnected, (d: Device) => this.removeDevice(d)); this.initDeviceRefresher(device); @@ -156,7 +156,7 @@ export default class DeviceManager this.detectedDeviceAcquireQueue.delete(deviceId.toString()); } - private initDeviceRefresher(device: Device): void { + private initDeviceRefresher(device: Device): void { this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId.toString()})`); const deviceRefreshIntervalMs = device.getRefreshInterval; diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index b48b25e6..6f4e5d58 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -5,10 +5,10 @@ import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import EventEmitter from 'events'; import { DeviceId } from './deviceId.js'; -export type InferPeripheralDeviceAttributes> = +export type InferPeripheralDeviceAttributes> = D extends PeripheralDevice ? TAttrs : DeviceAttributes; -export type InferPeripheralDeviceConfig> = +export type InferPeripheralDeviceConfig> = D extends PeripheralDevice ? TCfg : AnyDeviceConfig; export default abstract class PeripheralDevice< diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 8834b9aa..b5d56d0a 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -1,7 +1,6 @@ import { Exclude, Expose } from 'class-transformer'; import EventEmitter from 'events'; -import { Int, Float } from '../../../util/numbers.js'; -import Device from '../../device.js'; +import Device, { AttributeKeyOf, AttributeValueOf } from '../../device.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { NoDeviceConfig } from '../../deviceConfig.js'; import { Peripheral } from '@stoprocent/noble'; @@ -48,7 +47,7 @@ export default class AiroticDevice extends Device(attributeName: K, value: V): Promise { + public setAttribute, V extends AttributeValueOf>(attributeName: K, value: V): Promise { console.log(attributeName, value); throw new Error('Method not implemented.'); } diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 4a4ac5e8..d7b8c15c 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -17,7 +17,7 @@ import Settings from '../../../settings/settings.js'; import KnownDevice from '../../../settings/knownDevice.js'; import { DeviceId } from '../../deviceId.js'; -export default class AiroticDeviceProvider extends DeviceProvider +export default class AiroticDeviceProvider extends DeviceProvider { public static readonly providerName = 'airotic'; @@ -85,7 +85,8 @@ export default class AiroticDeviceProvider extends DeviceProvider } } - private createDevice(deviceInfo: BleDeviceInfo, peripheral: Peripheral, protocol: AiroticProtocol): AiroticDevice + private createDevice(deviceInfo: BleDeviceInfo, peripheral: Peripheral, + protocol: AiroticProtocol): AiroticDevice { const knownDevice = this.createKnownDevice(deviceInfo.id, deviceInfo.peripheral.advertisement.localName ?? `Airotic ${deviceInfo.id.toString()}`, AiroticDeviceProvider.providerName); diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index bac8ce45..5ab4130c 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -9,7 +9,7 @@ import SlvCtrlPlusButtplugWebsocketClientConnector from './slvCtrlPlusButtplugWe import DeviceManager from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; -export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { +export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { public static readonly providerName = 'buttplugIoWebsocket'; private connectedDevices: Map = new Map(); diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 25b7e588..b23bd6bc 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -10,7 +10,7 @@ import DeviceManager from '../../deviceManager.js'; import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; import { logError } from '../../../util/error.js'; -export default class VirtualDeviceProvider extends DeviceProvider> +export default class VirtualDeviceProvider extends DeviceProvider { public static readonly providerName = 'virtual'; diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 3f331388..eeada07b 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -1,14 +1,8 @@ import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; -import Device, { DeviceAttributes, InferDeviceAttributes, InferDeviceConfig } from '../device.js'; -import { AnyDeviceConfig } from '../deviceConfig.js'; import DeviceManager from '../deviceManager.js'; -export default abstract class DeviceProvider< - D extends Device, - TAttributes extends DeviceAttributes = InferDeviceAttributes, - TConfig extends AnyDeviceConfig = InferDeviceConfig -> +export default abstract class DeviceProvider { protected readonly deviceManager: DeviceManager; diff --git a/src/device/provider/deviceProviderFactory.ts b/src/device/provider/deviceProviderFactory.ts index d6e52584..4aa6c595 100644 --- a/src/device/provider/deviceProviderFactory.ts +++ b/src/device/provider/deviceProviderFactory.ts @@ -1,8 +1,7 @@ import DeviceProvider from './deviceProvider.js'; import { JsonObject } from '../../types.js'; -import Device from '../device.js'; -export default interface DeviceProviderFactory = DeviceProvider> +export default interface DeviceProviderFactory { - create(config: JsonObject): DeviceProvider ? D : Device>; + create(config: JsonObject): DP; } diff --git a/src/device/provider/deviceProviderLoader.ts b/src/device/provider/deviceProviderLoader.ts index 32d0dc90..80defee7 100644 --- a/src/device/provider/deviceProviderLoader.ts +++ b/src/device/provider/deviceProviderLoader.ts @@ -6,13 +6,13 @@ export default class DeviceProviderLoader { private settings: Settings; - private factories: Map; + private factories: Map>; private readonly logger: Logger; public constructor( settings: Settings, - factories: Map, + factories: Map>, logger: Logger ) { this.settings = settings; diff --git a/src/device/provider/genericDeviceProviderFactory.ts b/src/device/provider/genericDeviceProviderFactory.ts index 03f5d02d..abcfb9f6 100644 --- a/src/device/provider/genericDeviceProviderFactory.ts +++ b/src/device/provider/genericDeviceProviderFactory.ts @@ -4,18 +4,18 @@ import DeviceProviderFactory from './deviceProviderFactory.js'; type ConcreteCtor = new (...args: any[]) => T; export default class GenericDeviceProviderFactory< - C extends ConcreteCtor> -> implements DeviceProviderFactory + DP extends DeviceProvider +> implements DeviceProviderFactory { - private readonly ctor: C; - private readonly args: ConstructorParameters; + private readonly ctor: ConcreteCtor; + private readonly args: ConstructorParameters>; - public constructor(ctor: C, ...args: ConstructorParameters) { + public constructor(ctor: ConcreteCtor, ...args: ConstructorParameters>) { this.ctor = ctor; this.args = args; } - public create(): DeviceProvider { + public create(): DP { return new this.ctor(...this.args); } } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 96c267f6..ced7810f 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -7,12 +7,12 @@ import SerialPortFactory from '../../factory/serialPortFactory.js'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; -import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; -import { AnyDeviceConfig } from '../deviceConfig.js'; -import { DeviceAttributes } from '../device.js'; import { asyncHandler } from '../../util/async.js'; import { logError } from '../../util/error.js'; import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; +import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; +import { DeviceAttributes } from '../device.js'; +import { AnyDeviceConfig } from '../deviceConfig.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; @@ -20,7 +20,7 @@ export default abstract class SerialDeviceProvider< D extends PeripheralDevice, TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig -> extends DeviceProvider +> extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 25947bc6..455ca8ff 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -62,7 +62,7 @@ export default class SerialPortObserver if (!this.managedDevices.has(portInfo.serialNumber)) { const deviceInfo: SerialDeviceInfo = { - id: new DeviceId(portInfo.serialNumber), + id: DeviceId.fromPath(portInfo.serialNumber), portInfo }; diff --git a/src/serviceMap.ts b/src/serviceMap.ts index a1b9aede..bafc5d1f 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -12,7 +12,7 @@ import DeviceNameGenerator from './device/deviceNameGenerator.js'; import DeviceUpdaterInterface from './device/updater/deviceUpdaterInterface.js'; import UuidFactory from './factory/uuidFactory.js'; import DateFactory from './factory/dateFactory.js'; -import Settings from './settings/settings.js'; +import Settings, { SettingsSchema } from './settings/settings.js'; import SettingsManager from './settings/settingsManager.js'; import ScriptRuntime from './automation/scriptRuntime.js'; import ConnectedDeviceRepository from './repository/connectedDeviceRepository.js'; @@ -45,6 +45,11 @@ import SerialPortFactory from './factory/serialPortFactory.js'; import Estim2bDeviceFactory from './device/protocol/estim2b/estim2bDeviceFactory.js'; import EventEmitterFactory from './factory/eventEmitterFactory.js'; import BleObserver from './device/transport/bleObserver.js'; +import SlvCtrlPlusSerialDeviceProvider from './device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; +import Zc95SerialDeviceProvider from './device/protocol/zc95/zc95SerialDeviceProvider.js'; +import EStim2bSerialDeviceProvider from './device/protocol/estim2b/estim2bSerialDeviceProvider.js'; +import ButtplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; type ServiceMap = { @@ -65,11 +70,11 @@ type ServiceMap = { 'device.serial.factory.slvCtrlPlus': SlvCtrlPlusDeviceFactory, 'device.factory.zc95': Zc95DeviceFactory, 'device.factory.estim2b': Estim2bDeviceFactory, - 'device.provider.factory.slvCtrlPlusSerial': DeviceProviderFactory, - 'device.provider.factory.zc95Serial': DeviceProviderFactory, - 'device.provider.factory.estim2bSerial': DeviceProviderFactory, - 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, - 'device.provider.factory.airotic': DeviceProviderFactory, + 'device.provider.factory.slvCtrlPlusSerial': DeviceProviderFactory, + 'device.provider.factory.zc95Serial': DeviceProviderFactory, + 'device.provider.factory.estim2bSerial': DeviceProviderFactory, + 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, + 'device.provider.factory.airotic': DeviceProviderFactory, 'device.serial.factory.buttplugIo': ButtplugIoDeviceFactory, 'device.virtual.provider': VirtualDeviceProvider, 'device.virtual.factory': VirtualDeviceFactory, @@ -91,7 +96,7 @@ type ServiceMap = { /* settingsServiceProvider */ 'settings': Settings, 'settings.manager': SettingsManager, - 'settings.schema.validator': JsonSchemaValidator, + 'settings.schema.validator': JsonSchemaValidator, /* automationServiceProvider */ 'automation.scriptRuntime': ScriptRuntime, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index f6726b62..fd42763c 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -38,6 +38,7 @@ import EStim2bSerialDeviceProvider from '../device/protocol/estim2b/estim2bSeria import Estim2bDeviceFactory from '../device/protocol/estim2b/estim2bDeviceFactory.js'; import BleObserver from '../device/transport/bleObserver.js'; import AiroticDeviceProvider from '../device/protocol/airotic/airoticDeviceProvider.js'; +import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -170,7 +171,7 @@ export default class DeviceServiceProvider implements ServiceProvider { return new DeviceProviderLoader( container.get('settings'), - new Map([ + new Map>([ [ SlvCtrlPlusSerialDeviceProvider.providerName, container.get('device.provider.factory.slvCtrlPlusSerial'), diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index 0ed78a56..d3a32509 100644 --- a/src/serviceProvider/settingsServiceProvider.ts +++ b/src/serviceProvider/settingsServiceProvider.ts @@ -3,8 +3,7 @@ import SettingsManager from '../settings/settingsManager.js'; import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import { SettingsSchema } from '../settings/settings.js'; export default class SettingsServiceProvider implements ServiceProvider { @@ -12,10 +11,7 @@ export default class SettingsServiceProvider implements ServiceProvider { const jsonSchemaValidatorFactory = container.get('factory.validator.schema.json'); - const dirname = path.dirname(fileURLToPath(import.meta.url)); - const settingsSchemaPath = path.resolve(dirname, '../../resources/schemas/settings.schema.json'); - - return jsonSchemaValidatorFactory.createFromFile(settingsSchemaPath); + return jsonSchemaValidatorFactory.create(SettingsSchema); }); container.set('settings.manager', () => { diff --git a/src/settings/knownDevice.ts b/src/settings/knownDevice.ts index 43eda19d..bbf224b3 100644 --- a/src/settings/knownDevice.ts +++ b/src/settings/knownDevice.ts @@ -1,4 +1,4 @@ -import { Exclude, Expose, Type } from 'class-transformer'; +import { Exclude, Expose } from 'class-transformer'; import { JsonObject } from '../types.js'; import { DeviceId } from '../device/deviceId.js'; @@ -6,7 +6,6 @@ import { DeviceId } from '../device/deviceId.js'; export default class KnownDevice { @Expose({ name: 'id' }) - @Type(() => DeviceId) private readonly _id: DeviceId; @Expose({ name: 'name' }) diff --git a/src/util/expressUtils.ts b/src/util/expressUtils.ts index a77fa468..d2423653 100644 --- a/src/util/expressUtils.ts +++ b/src/util/expressUtils.ts @@ -1,14 +1,16 @@ import type { Request, Response } from 'express'; import type { Pimple } from '@timesplinter/pimple'; import type ServiceMap from '../serviceMap.js'; +import ControllerInterface from '../controller/controllerInterface.js'; export type ControllerKey = { [K in keyof ServiceMap]: K extends `controller.${string}` ? K : never }[keyof ServiceMap]; -export const executeController = ( +export const executeController = ( container: Pimple, - controllerName: ControllerKey + controllerName: K ): (req: Request, res: Response) => void | Promise => { - return (req: Request, res: Response) => container.get(controllerName).execute(req, res); + const controller: ControllerInterface = container.get(controllerName); + return (req: Request, res: Response) => controller.execute(req, res); } From 79cfb87cd969e83edb7b13c616d952dbbf1f2b75 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 25 Mar 2026 22:34:24 +0100 Subject: [PATCH 09/42] Slight improvement --- src/controller/controllerInterface.ts | 4 ++-- src/util/expressUtils.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/controller/controllerInterface.ts b/src/controller/controllerInterface.ts index ee1df132..316964ba 100644 --- a/src/controller/controllerInterface.ts +++ b/src/controller/controllerInterface.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; -export default interface ControllerInterface +export default interface ControllerInterface { - execute(req: Req, res: Res): void | Promise; + execute(req: Request, res: Response): void | Promise; } diff --git a/src/util/expressUtils.ts b/src/util/expressUtils.ts index d2423653..782e73ab 100644 --- a/src/util/expressUtils.ts +++ b/src/util/expressUtils.ts @@ -11,6 +11,8 @@ export const executeController = ( container: Pimple, controllerName: K ): (req: Request, res: Response) => void | Promise => { - const controller: ControllerInterface = container.get(controllerName); - return (req: Request, res: Response) => controller.execute(req, res); + return (req: Request, res: Response) => { + const controller: ControllerInterface = container.get(controllerName); + return controller.execute(req, res); + } } From 28c035fb2be36c30d2eb0f066acdb809321c88d1 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 25 Mar 2026 22:43:03 +0100 Subject: [PATCH 10/42] Remove unnecessary toString() calls --- src/device/deviceManager.ts | 38 +++++++++---------- src/device/protocol/airotic/airoticDevice.ts | 2 +- .../buttplugIo/buttplugIoDeviceFactory.ts | 2 +- .../buttplugIoWebsocketDeviceProvider.ts | 4 +- .../slvCtrlPlus/genericSlvCtrlPlusDevice.ts | 4 +- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 2 +- src/device/protocol/virtual/virtualDevice.ts | 2 +- .../protocol/virtual/virtualDeviceProvider.ts | 10 ++--- src/device/provider/serialDeviceProvider.ts | 14 +++---- src/device/transport/serialPortObserver.ts | 4 +- src/settings/settings.ts | 4 +- 11 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index a7dbc450..5a12cd9f 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -47,35 +47,35 @@ export default class DeviceManager public announceDetectedDevice(deviceInfo: DeviceInfo): void { - if (this.detectedDeviceAcquireQueue.has(deviceInfo.id.toString())) { + if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)) { return; } - this.logger.info(`Announced new BLE device with id ${deviceInfo.id.toString()}`); + this.logger.info(`Announced new BLE device with id ${deviceInfo.id}`); - this.detectedDeviceAcquireQueue.set(deviceInfo.id.toString(), []); + this.detectedDeviceAcquireQueue.set(deviceInfo.id, []); const hadListeners = this.eventEmitter.emit(DeviceManagerEvent.deviceDetected, deviceInfo); if (!hadListeners) { // no subscribed providers, remove empty list from acquire queue for this device - this.logger.info(`No provider available for detected device with id '${deviceInfo.id.toString()}'`); - this.detectedDeviceAcquireQueue.delete(deviceInfo.id.toString()); + this.logger.info(`No provider available for detected device with id '${deviceInfo.id}'`); + this.detectedDeviceAcquireQueue.delete(deviceInfo.id); } } public revokeDetectedDevice(deviceInfo: DeviceInfo): void { - this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id.toString()}' has disappeared`); + this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); } public async acquireDetectedDevice(deviceId: DeviceId): Promise { return new Promise((resolve) => { - const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId.toString()); + const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); if (undefined === deviceQueue) { - resolve({ successful: false, reason: `Device with id '${deviceId.toString()}' is not available for claiming` }); + resolve({ successful: false, reason: `Device with id '${deviceId}' is not available for claiming` }); return; } @@ -91,7 +91,7 @@ export default class DeviceManager public releaseDetectedDevice(deviceId: DeviceId): void { - const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId.toString()); + const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); if (undefined === deviceQueue) { return; @@ -101,7 +101,7 @@ export default class DeviceManager deviceQueue.shift(); if (deviceQueue.length === 0) { - this.detectedDeviceAcquireQueue.delete(deviceId.toString()); + this.detectedDeviceAcquireQueue.delete(deviceId); return; } @@ -112,7 +112,7 @@ export default class DeviceManager device: Device ): void { - this.connectedDevices.set(device.getDeviceId.toString(), device); + this.connectedDevices.set(device.getDeviceId, device); device.on(DeviceEvent.deviceRefreshed, (d: Device) => this.refreshDevice(d)); device.on(DeviceEvent.deviceDisconnected, (d: Device) => this.removeDevice(d)); @@ -124,7 +124,7 @@ export default class DeviceManager public claimDetectedDevice(deviceId: DeviceId): void { - this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId.toString()}' has been claimed by another provider`); + this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId}' has been claimed by another provider`); } public getConnectedDevices(): Device[] @@ -149,15 +149,15 @@ export default class DeviceManager private clearDetectedDeviceAcquireQueue(deviceId: DeviceId, reason: string): void { - for (const entry of this.detectedDeviceAcquireQueue.get(deviceId.toString()) ?? []) { + for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { entry.resolve({ successful: false, reason }); } - this.detectedDeviceAcquireQueue.delete(deviceId.toString()); + this.detectedDeviceAcquireQueue.delete(deviceId); } private initDeviceRefresher(device: Device): void { - this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId.toString()})`); + this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId})`); const deviceRefreshIntervalMs = device.getRefreshInterval; if (undefined === deviceRefreshIntervalMs) { @@ -166,19 +166,19 @@ export default class DeviceManager const deviceRefresher = async (): Promise => { if (device.getState === DeviceState.busy) { - this.logger.trace(`Device not refreshed since it's currently busy: ${device.getDeviceId.toString()}`); + this.logger.trace(`Device not refreshed since it's currently busy: ${device.getDeviceId}`); return; } await device.refresh(); - this.logger.trace(`device: ${device.getDeviceId.toString()} -> refresh -> successful`); + this.logger.trace(`device: ${device.getDeviceId} -> refresh -> successful`); }; const deviceRefreshInterval = setIntervalAsync(deviceRefresher, { intervalMs: deviceRefreshIntervalMs, timeoutMs: deviceRefreshIntervalMs * 3, - onError: (e: unknown) => logError(this.logger, `device: ${device.getDeviceId.toString()} -> refresh -> failed`, e), + onError: (e: unknown) => logError(this.logger, `device: ${device.getDeviceId} -> refresh -> failed`, e), }); device.on(DeviceEvent.deviceDisconnected, () => deviceRefreshInterval.clear()); @@ -186,7 +186,7 @@ export default class DeviceManager private removeDevice(device: Device): void { - this.connectedDevices.delete(device.getDeviceId.toString()); + this.connectedDevices.delete(device.getDeviceId); this.eventEmitter.emit(DeviceManagerEvent.deviceDisconnected, device); } diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index b5d56d0a..a99a070d 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -37,7 +37,7 @@ export default class AiroticDevice extends Device { peripheral.updateRssi((error, rssi) => { if (error) { - console.error(`Error updating RSSI for device ${this.deviceId.toString()}`, error); + console.error(`Error updating RSSI for device ${this.deviceId}`, error); return; } diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index b7bf785e..0624282d 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -121,7 +121,7 @@ export default class ButtplugIoDeviceFactory if (undefined !== knownDevice) { // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id.toString()}`); + this.logger.debug(`Device is already known: ${knownDevice.id}`); return knownDevice; } diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 5ab4130c..40fcb38f 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -128,7 +128,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.deviceManager.addDevice(device); this.logger.debug(`Assigned device id: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`); - this.logger.info('Connected devices: ' + this.connectedDevices.size.toString()); + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } catch (e: unknown) { logError(this.logger, `Could not connect to device '${buttplugDevice.name}'`, e); } @@ -153,6 +153,6 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { logError(this.logger, `Could not remove device '${device.getDeviceId}' (${buttplugDevice.name}@${buttplugDevice.index})`, e); } - this.logger.info('Connected devices: ' + this.connectedDevices.size.toString()); + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } } diff --git a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts index 5001657f..41dd960c 100644 --- a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts @@ -68,11 +68,11 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice const attr = this.attributes[attributeName]; if (undefined === attr) { - throw new Error(`Attribute with name '${attributeName.toString()}' does not exist for this device`); + throw new Error(`Attribute with name '${attributeName}' does not exist for this device`); } if (undefined === value || null === value) { - throw new Error(`A non-null value must be set for the attribute with name '${attributeName.toString()}'`); + throw new Error(`A non-null value must be set for the attribute with name '${attributeName}'`); } if (!attr.isValidValue(value)) { diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index b0fc8dea..dc7f9c0f 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -131,7 +131,7 @@ export default class SlvCtrlPlusDeviceFactory if (undefined !== knownDevice) { // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id.toString()}`); + this.logger.debug(`Device is already known: ${knownDevice.id}`); return knownDevice; } diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index 475e0aa8..40961ea5 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -73,7 +73,7 @@ export default class VirtualDevice< if (undefined === attribute || null === attribute) { reject(new Error( - `Attribute named "${attributeName.toString()}" does not exist for device with id "${this.deviceId.toString()}"` + `Attribute named "${attributeName}" does not exist for device with id "${this.deviceId}"` )); return; } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index b23bd6bc..68b36aed 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -80,16 +80,16 @@ export default class VirtualDeviceProvider extends DeviceProvider this.deviceManager.addDevice(device); - this.connectedDevices.set(knowDevice.id.toString(), device); + this.connectedDevices.set(knowDevice.id, device); - this.logger.info('Connected virtual devices: ' + this.connectedDevices.size.toString()); + this.logger.info(`Connected virtual devices: ${this.connectedDevices.size}`); } catch (e: unknown) { - logError(this.logger, `Could not initiate virtual device '${knowDevice.id.toString()}'`, e); + logError(this.logger, `Could not initiate virtual device '${knowDevice.id}'`, e); } } private async removeDevice(device: Device): Promise { - const deviceId = device.getDeviceId.toString(); + const deviceId = device.getDeviceId; try { await device.close(); @@ -99,6 +99,6 @@ export default class VirtualDeviceProvider extends DeviceProvider } this.logger.info(`Device removed: ${deviceId} (${device.getDeviceName})`); - this.logger.info(`Connected devices: ${this.connectedDevices.size.toString()}`); + this.logger.info(`Connected virtual devices: ${this.connectedDevices.size}`); } } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index ced7810f..c7f93ac7 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -45,7 +45,7 @@ export default abstract class SerialDeviceProvider< return; } - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id.toString()}`); + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); @@ -119,16 +119,16 @@ export default abstract class SerialDeviceProvider< } else { this.logger.info(`Successfully connected to serial device '${portInfo.path}'`); - this.connectedDevices.set(device.getDeviceId.toString(), device); + this.connectedDevices.set(device.getDeviceId, device); - this.logger.debug(`Assigned device id: ${device.getDeviceId.toString()} (${portInfo.path})`); - this.logger.info('Connected devices: ' + this.connectedDevices.size.toString()); + this.logger.debug(`Assigned device id: ${device.getDeviceId} (${portInfo.path})`); + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); port.on('close', () => { - this.connectedDevices.delete(device.getDeviceId.toString()); + this.connectedDevices.delete(device.getDeviceId); - this.logger.info('Lost serial device: ' + device.getDeviceId.toString()); - this.logger.info('Connected devices: ' + this.connectedDevices.size.toString()); + this.logger.info(`Lost serial device: ${device.getDeviceId}`); + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); }); } diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 455ca8ff..b2f58a04 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -67,7 +67,7 @@ export default class SerialPortObserver }; this.managedDevices.set(portInfo.serialNumber, deviceInfo); - this.logger.debug('Managed devices: ' + this.managedDevices.size.toString()); + this.logger.debug(`Managed devices: ${this.managedDevices.size}`); this.deviceManager.announceDetectedDevice(deviceInfo); } @@ -78,7 +78,7 @@ export default class SerialPortObserver if (!foundDevices.has(key)) { this.deviceManager.revokeDetectedDevice(deviceInfo); this.managedDevices.delete(key); - this.logger.info('Managed devices: ' + this.managedDevices.size.toString()); + this.logger.info(`Managed devices: ${this.managedDevices.size}`); } } } catch (err) { diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 7b91f7a3..b10714fd 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -75,12 +75,12 @@ export default class Settings public getKnownDeviceById(id: DeviceId): KnownDevice|undefined { // Return already existing device if already known (previously detected serial number) - return this.knownDevices.get(id.toString()); + return this.knownDevices.get(id); } public addKnownDevice(knownDevice: KnownDevice): void { - this.knownDevices.set(knownDevice.id.toString(), knownDevice); + this.knownDevices.set(knownDevice.id, knownDevice); } public addDeviceSource(deviceSource: DeviceSource): void From 85d4cedf41ee00dc9dcb1fb18c3014a2c100f88b Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 29 Mar 2026 12:53:02 +0200 Subject: [PATCH 11/42] Protcol improvements, Airtonic kind of working --- package-lock.json | 236 +++--------------- package.json | 4 +- src/device/bleDevice.ts | 96 +++++++ src/device/deviceManager.ts | 11 +- src/device/peripheralDevice.ts | 4 +- src/device/protocol/airotic/airoticDevice.ts | 79 +++--- .../protocol/airotic/airoticDeviceProvider.ts | 31 ++- .../protocol/airotic/airtonicProtocol.ts | 42 +++- src/device/protocol/deviceProtocol.ts | 23 +- .../protocol/estim2b/estim2bProtocol.ts | 12 +- src/device/protocol/messageResponseHandler.ts | 27 +- .../protocol/slvCtrlPlus/slvCtrlProtocol.ts | 12 +- .../slvCtrlPlus/slvCtrlProtocolLegacy.ts | 9 +- .../protocol/slvCtrlPlus/slvCtrlProtocolV1.ts | 11 +- src/device/protocol/zc95/zc95Device.ts | 3 +- .../protocol/zc95/zc95MessageFactory.ts | 183 +++++--------- src/device/protocol/zc95/zc95Protocol.ts | 55 +++- src/device/transport/bleDeviceTransport.ts | 7 +- src/device/transport/bleObserver.ts | 2 +- src/index.ts | 2 + src/util/async.ts | 19 ++ src/util/createMapTransformFn.ts | 1 - 22 files changed, 439 insertions(+), 430 deletions(-) create mode 100644 src/device/bleDevice.ts diff --git a/package-lock.json b/package-lock.json index b01864da..b9970fee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "modern-errors": "^7.1.4", "node-os-utils": "^1.3.7", "on-change": "^4.0.1", - "pino": "^10.1.0", + "pino": "^10.3.1", "read-last-lines": "^1.8.0", "reflect-metadata": "^0.1.13", "say": "^0.16.0", @@ -55,7 +55,7 @@ "globals": "^17.0.0", "jiti": "^2.6.1", "nodemon": "^3.0.2", - "pino-pretty": "^10.3.0", + "pino-pretty": "^13.1.3", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", "vitest": "^4.0.16", @@ -4095,19 +4095,6 @@ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "license": "BSD-2-Clause" }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4414,27 +4401,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -4548,31 +4514,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-alloc": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", @@ -5787,32 +5728,12 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -5867,9 +5788,9 @@ } }, "node_modules/fast-copy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", - "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", "dev": true, "license": "MIT" }, @@ -6492,27 +6413,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8090,9 +7990,9 @@ } }, "node_modules/pino": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.1.tgz", - "integrity": "sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", @@ -8121,52 +8021,30 @@ } }, "node_modules/pino-pretty": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.3.1.tgz", - "integrity": "sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==", + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", "dev": true, "license": "MIT", "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", - "fast-copy": "^3.0.0", + "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^1.0.0", + "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", - "readable-stream": "^4.0.0", - "secure-json-parse": "^2.4.0", - "sonic-boom": "^3.0.0", - "strip-json-comments": "^3.1.1" + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, - "node_modules/pino-pretty/node_modules/pino-abstract-transport": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", - "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^4.0.0", - "split2": "^4.0.0" - } - }, - "node_modules/pino-pretty/node_modules/sonic-boom": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", - "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, "node_modules/pino-std-serializers": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", @@ -8311,16 +8189,6 @@ "node": ">= 0.8.0" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -8432,23 +8300,6 @@ "mz": "^2.7.0" } }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8722,27 +8573,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8806,10 +8636,20 @@ } }, "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause" }, "node_modules/semver": { @@ -9400,16 +9240,6 @@ "node": ">= 0.4" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -9470,13 +9300,13 @@ } }, "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index c26f71bb..94dc3b3b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "modern-errors": "^7.1.4", "node-os-utils": "^1.3.7", "on-change": "^4.0.1", - "pino": "^10.1.0", + "pino": "^10.3.1", "read-last-lines": "^1.8.0", "reflect-metadata": "^0.1.13", "say": "^0.16.0", @@ -53,7 +53,7 @@ "globals": "^17.0.0", "jiti": "^2.6.1", "nodemon": "^3.0.2", - "pino-pretty": "^10.3.0", + "pino-pretty": "^13.1.3", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", "vitest": "^4.0.16", diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts new file mode 100644 index 00000000..12644c39 --- /dev/null +++ b/src/device/bleDevice.ts @@ -0,0 +1,96 @@ +import { Peripheral } from '@stoprocent/noble'; +import BaseError from 'modern-errors'; +import Device, { DeviceAttributes } from './device.js'; +import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; +import { Expose } from 'class-transformer'; +import { EventEmitter } from 'events'; +import { DeviceId } from './deviceId.js'; +import { logError } from '../util/error.js'; +import Logger from '../logging/Logger.js'; +import { asyncHandler, promiseWithTimeout } from '../util/async.js'; + +export default abstract class BleDevice< + TAttributes extends DeviceAttributes = DeviceAttributes, + TConfig extends AnyDeviceConfig = NoDeviceConfig +> extends Device +{ + private readonly peripheral: Peripheral; + + @Expose() + private rssi: number; + + protected logger: Logger; + + protected constructor( + deviceId: DeviceId, + deviceName: string, + provider: string, + peripheral: Peripheral, + connectedSince: Date, + controllable: boolean, + attributes: TAttributes, + config: TConfig, + eventEmitter: EventEmitter, + logger: Logger, + ) { + super(deviceId, deviceName, provider, connectedSince, controllable, attributes, config, eventEmitter); + + this.logger = logger.child({ name: this.constructor.name }); + + this.peripheral = peripheral; + this.rssi = peripheral.rssi; + + const rssiInterval = setInterval(asyncHandler( + async () => await this.requestRssiUpdate(), + (e: unknown) => logError(this.logger,`Error during RSSI update for device ${this.deviceId}`, e) + ), 5000); + + const reconnectHandler = asyncHandler( + async () => { + this.logger.info(`BLE Device ${this.deviceId} disconnected, trying to reconnect`); + try { + await promiseWithTimeout(peripheral.connectAsync(), 2000); + this.logger.info(`BLE Device ${this.deviceId} reconnected successfully`); + } catch (e) { + const error = BaseError.normalize(e); + this.logger.warn(`Error reconnecting to device ${this.deviceId}: ${error.message}`); + peripheral.off('disconnect', reconnectHandler); + clearInterval(rssiInterval); + await this.close(); + } + }, + (err: unknown) => logError(this.logger, `Error in reconnect handler for device ${this.deviceId}`, err) + ); + + peripheral.on('disconnect', reconnectHandler); + } + + private async requestRssiUpdate(): Promise { + if (this.peripheral.state === 'disconnected') { + return; + } + + try { + const rssi = await promiseWithTimeout(this.peripheral.updateRssiAsync(), 1000); + this.logger.debug(`Received RSSI update for device ${this.deviceId}: ${rssi}`); + + this.rssi = rssi; + this.updateLastRefresh(); + } catch (e: unknown) { + const error = BaseError.normalize(e); + this.logger.warn(`Error updating RSSI for device ${this.deviceId}: ${error.message}`); + } + } + + protected override async doClose(): Promise { + if (this.peripheral.state === 'connected') { + try { + await promiseWithTimeout(this.peripheral.disconnectAsync(), 1000); + } catch (error) { + logError(this.logger, `Error disconnecting device ${this.deviceId}`, error); + } + } else if (this.peripheral.state === 'connecting') { + this.peripheral.cancelConnect(); + } + } +} \ No newline at end of file diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 5a12cd9f..a094e8b6 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -41,17 +41,22 @@ export default class DeviceManager public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { this.eventEmitter = eventEmitter; - this.logger = logger; + this.logger = logger.child({ name: DeviceManager.name }); this.connectedDevices = connectedDevices; } public announceDetectedDevice(deviceInfo: DeviceInfo): void { - if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)) { + if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)/* || this.connectedDevices.has(deviceInfo.id)*/) { return; } - this.logger.info(`Announced new BLE device with id ${deviceInfo.id}`); + if (this.connectedDevices.has(deviceInfo.id)) { + this.logger.debug(`Device with id '${deviceInfo.id}' is already connected, not announcing it as detected`); + return; + } + + this.logger.info(`Detected new device with id ${deviceInfo.id}`); this.detectedDeviceAcquireQueue.set(deviceInfo.id, []); diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 6f4e5d58..40d9d143 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -1,6 +1,6 @@ import Device, { DeviceAttributes } from './device.js'; import DeviceTransport from './transport/deviceTransport.js'; -import DeviceProtocol, { MessageResponse } from './protocol/deviceProtocol.js'; +import DeviceProtocol, { MessageWithResponse } from './protocol/deviceProtocol.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import EventEmitter from 'events'; import { DeviceId } from './deviceId.js'; @@ -12,7 +12,7 @@ export type InferPeripheralDeviceConfig ? TCfg : AnyDeviceConfig; export default abstract class PeripheralDevice< - TProtocol extends DeviceProtocol>, + TProtocol extends DeviceProtocol>, TAttributes extends DeviceAttributes = DeviceAttributes, TConfig extends AnyDeviceConfig = NoDeviceConfig > extends Device diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index a99a070d..f86c664e 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -1,54 +1,77 @@ -import { Exclude, Expose } from 'class-transformer'; +import { Exclude } from 'class-transformer'; import EventEmitter from 'events'; -import Device, { AttributeKeyOf, AttributeValueOf } from '../../device.js'; +import { AttributeKeyOf, AttributeValueOf } from '../../device.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { NoDeviceConfig } from '../../deviceConfig.js'; import { Peripheral } from '@stoprocent/noble'; import { DeviceId } from '../../deviceId.js'; +import Logger from '../../../logging/Logger.js'; +import BleDevice from '../../bleDevice.js'; +import MessageResponseHandler from '../messageResponseHandler.js'; +import AiroticProtocol from './airtonicProtocol.js'; +import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; export type AiroticDeviceAttributes = { - color: StrDeviceAttribute, + restColor: StrDeviceAttribute, + breathInColor: StrDeviceAttribute, + resetColors: BoolDeviceAttribute, + reboot: BoolDeviceAttribute, }; @Exclude() -export default class AiroticDevice extends Device +export default class AiroticDevice extends BleDevice { - private readonly peripheral: Peripheral; - - @Expose() - private rssi: number; + private readonly messageResponseHandler: MessageResponseHandler; public constructor( deviceId: DeviceId, deviceName: string, provider: string, peripheral: Peripheral, + messageResponseHandler: MessageResponseHandler, connectedSince: Date, controllable: boolean, attributes: AiroticDeviceAttributes, config: NoDeviceConfig, - eventEmitter: EventEmitter + eventEmitter: EventEmitter, + logger: Logger, ) { - super(deviceId, deviceName, provider, connectedSince, controllable, attributes, config, eventEmitter); - - this.peripheral = peripheral; - this.rssi = peripheral.rssi; - - setInterval(() => { - peripheral.updateRssi((error, rssi) => { - if (error) { - console.error(`Error updating RSSI for device ${this.deviceId}`, error); - return; - } - - this.rssi = rssi; - this.updateLastRefresh(); - }); - }, 5000); + super(deviceId, deviceName, provider, peripheral, connectedSince, controllable, attributes, config, eventEmitter, logger); + + this.messageResponseHandler = messageResponseHandler; } - public setAttribute, V extends AttributeValueOf>(attributeName: K, value: V): Promise { - console.log(attributeName, value); - throw new Error('Method not implemented.'); + public async setAttribute, V extends AttributeValueOf>(attributeName: K, value: V): Promise { + console.log(`Setting attribute ${attributeName} to value ${value}`); + + if (attributeName === 'restColor' && value !== null && typeof value === 'string') { + const [r, g, b] = (value.split(',').map(c => parseInt(c, 10))); + await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); + await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); + return value; + } + + if (attributeName === 'breathInColor' && value !== null && typeof value === 'string') { + const [r, g, b] = (value.split(',').map(c => parseInt(c, 10))); + await this.messageResponseHandler.send(AiroticProtocol.createSelectBreathInColorMessage()); + await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); + return value; + } + + if (attributeName === 'resetColors' && typeof value === 'boolean') { + if (value) { + await this.messageResponseHandler.send(AiroticProtocol.createResetColorsMessage()); + } + return value; + } + + if (attributeName === 'reboot' && typeof value === 'boolean') { + if (value) { + await this.messageResponseHandler.send(AiroticProtocol.createRebootMessage()); + } + return value; + } + + throw new Error(`Unknown attribute '${attributeName}' or invalid value type`); } } diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index d7b8c15c..07b92c46 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -16,6 +16,7 @@ import { Peripheral } from '@stoprocent/noble'; import Settings from '../../../settings/settings.js'; import KnownDevice from '../../../settings/knownDevice.js'; import { DeviceId } from '../../deviceId.js'; +import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; export default class AiroticDeviceProvider extends DeviceProvider { @@ -47,14 +48,14 @@ export default class AiroticDeviceProvider extends DeviceProvider } private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - this.logger.debug(`Handling detected device with id ${deviceInfo.id.toString()}`); + this.logger.debug(`Handling detected device with id ${deviceInfo.id}`); if (!this.isBleDeviceInfo(deviceInfo)) { return; } try { - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id.toString()}`); + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); await this.deviceManager.acquireDetectedDevice(deviceInfo.id); @@ -64,7 +65,7 @@ export default class AiroticDeviceProvider extends DeviceProvider AiroticDeviceProvider.UART_TX_CHAR_UUID ); - this.logger.debug(`Connected to device: ${deviceInfo.id.toString()}`); + this.logger.debug(`Connected to device: ${deviceInfo.id}`); const protocol = new AiroticProtocol(); @@ -73,7 +74,7 @@ export default class AiroticDeviceProvider extends DeviceProvider const handshakeResult = await this.doHandshake(messageResponseHandler); if (handshakeResult) { - this.deviceManager.addDevice(this.createDevice(deviceInfo, deviceInfo.peripheral, protocol)); + this.deviceManager.addDevice(this.createDevice(deviceInfo, deviceInfo.peripheral, messageResponseHandler, protocol)); this.deviceManager.claimDetectedDevice(deviceInfo.id); } else { @@ -85,23 +86,32 @@ export default class AiroticDeviceProvider extends DeviceProvider } } - private createDevice(deviceInfo: BleDeviceInfo, peripheral: Peripheral, - protocol: AiroticProtocol): AiroticDevice + private createDevice( + deviceInfo: BleDeviceInfo, + peripheral: Peripheral, + messageResponseHandler: MessageResponseHandler, + protocol: AiroticProtocol + ): AiroticDevice { - const knownDevice = this.createKnownDevice(deviceInfo.id, deviceInfo.peripheral.advertisement.localName ?? `Airotic ${deviceInfo.id.toString()}`, AiroticDeviceProvider.providerName); + const knownDevice = this.createKnownDevice(deviceInfo.id, deviceInfo.peripheral.advertisement.localName ?? `Airotic ${deviceInfo.id}`, AiroticDeviceProvider.providerName); const device = new AiroticDevice( knownDevice.id, knownDevice.name, AiroticDeviceProvider.providerName, peripheral, + messageResponseHandler, new Date(), true, { - color: new StrDeviceAttribute('color', 'Color', DeviceAttributeModifier.readWrite, '') + restColor: new StrDeviceAttribute('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, ''), + breathInColor: new StrDeviceAttribute('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, ''), + resetColors: new BoolDeviceAttribute('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly, false), + reboot: new BoolDeviceAttribute('reboot', 'Reboot bottle', DeviceAttributeModifier.writeOnly, false), }, {}, new EventEmitter(), + this.logger, ); this.settings.addKnownDevice(knownDevice); @@ -111,7 +121,8 @@ export default class AiroticDeviceProvider extends DeviceProvider private async doHandshake(messageResponseHandler: MessageResponseHandler): Promise { try { - await messageResponseHandler.send(AiroticProtocol.createHelloMessage(), 500); + const answer = await messageResponseHandler.send(AiroticProtocol.createHelloMessage(), 500); + this.logger.debug(`Received handshake answer: ${answer}`); return true; } catch (e: unknown) { const error = BaseError.normalize(e); @@ -127,7 +138,7 @@ export default class AiroticDeviceProvider extends DeviceProvider if (undefined !== knownDevice) { // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id.toString()}`); + this.logger.debug(`Device is already known: ${knownDevice.id}`); return knownDevice; } diff --git a/src/device/protocol/airotic/airtonicProtocol.ts b/src/device/protocol/airotic/airtonicProtocol.ts index e8950255..3024cc32 100644 --- a/src/device/protocol/airotic/airtonicProtocol.ts +++ b/src/device/protocol/airotic/airtonicProtocol.ts @@ -1,9 +1,13 @@ -import DeviceProtocol, { DecodeResult, MessageResponse } from '../deviceProtocol.js'; +import DeviceProtocol, { DecodeResult, Message, MessageWithResponse } from '../deviceProtocol.js'; -export default class AiroticProtocol implements DeviceProtocol> +export type AiroticProtocolMessageWithResponse = MessageWithResponse; +export type AiroticProtocolMessageWithoutResponse = Message; +export type AiroticProtocolMessage = AiroticProtocolMessageWithResponse | AiroticProtocolMessageWithoutResponse; + +export default class AiroticProtocol implements DeviceProtocol { - public encode(message: string): Buffer { - return Buffer.from(message, 'utf8'); + public encode(message: Buffer): Buffer { + return message; } public decode(data: Buffer): DecodeResult { @@ -12,15 +16,37 @@ export default class AiroticProtocol implements DeviceProtocol): boolean { - if (message.message === '!H' && response.startsWith('Hello I am bottle')) { + public isResponseMatchingMessage(response: string, message: AiroticProtocolMessage): boolean { + if (message.message.equals(Buffer.from('!H')) && response.startsWith('Hello I am bottle')) { return true; } return false; } - public static createHelloMessage(): MessageResponse { - return { message: '!H' }; + public static createHelloMessage(): AiroticProtocolMessageWithResponse { + return { message: Buffer.from('!H', 'utf8'), responseType: undefined }; + } + + public static createSelectRestColorMessage(): AiroticProtocolMessageWithoutResponse { + return { message: Buffer.from('!B1', 'utf8') }; + } + + public static createSelectBreathInColorMessage(): AiroticProtocolMessageWithoutResponse { + return { message: Buffer.from('!B2', 'utf8') }; + } + + public static createSetColorMessage(r: number, g: number, b: number): AiroticProtocolMessageWithoutResponse { + const command = Buffer.from('!C', 'utf8'); + const color = Buffer.from([r, g, b]); + return { message: Buffer.concat([command, color]) }; + } + + public static createResetColorsMessage(): AiroticProtocolMessageWithoutResponse { + return { message: Buffer.from('!B3', 'utf8') }; + } + + public static createRebootMessage(): AiroticProtocolMessageWithoutResponse { + return { message: Buffer.from('!B4', 'utf8') }; } } diff --git a/src/device/protocol/deviceProtocol.ts b/src/device/protocol/deviceProtocol.ts index f54e42cb..2dcad029 100644 --- a/src/device/protocol/deviceProtocol.ts +++ b/src/device/protocol/deviceProtocol.ts @@ -7,18 +7,23 @@ export type DecodeResult = | { message: TMessage } | { error: ProtocolError }; -// eslint-disable-next-line no-underscore-dangle -declare const __responseType__: unique symbol; +export type Message = { + message: T; +} + +export type MessageWithResponse = Message & { + responseType: R|undefined; +} -export type MessageResponse = { - message: TMessage; -} & { [__responseType__]?: TResponse }; +export type MessageWithOptionalResponse = Message|MessageWithResponse; -export type InferMR

= P extends DeviceProtocol> ? MR : never; -export type InferMessage = MR extends MessageResponse ? M : never; -export type InferResponse = MR extends MessageResponse ? R : never; +export type InferMR

= P extends DeviceProtocol> ? T : never; +export type InferMessage = MR extends MessageWithResponse ? M : + MR extends Message ? M : never; +export type InferResponse = MR extends MessageWithResponse ? R : + MR extends Message ? void : never; -export default interface DeviceProtocol> +export default interface DeviceProtocol> { encode(message: InferMessage): Buffer; decode(data: Buffer): DecodeResult>; diff --git a/src/device/protocol/estim2b/estim2bProtocol.ts b/src/device/protocol/estim2b/estim2bProtocol.ts index b54e322b..58147afe 100644 --- a/src/device/protocol/estim2b/estim2bProtocol.ts +++ b/src/device/protocol/estim2b/estim2bProtocol.ts @@ -1,4 +1,4 @@ -import DeviceProtocol, { DecodeResult, MessageResponse } from '../deviceProtocol.js'; +import DeviceProtocol, { DecodeResult, InferMessage, MessageWithResponse } from '../deviceProtocol.js'; export type EStim2bStatus = { batteryLevel: number, @@ -53,13 +53,15 @@ export type Estim2bCommand = | Estim2bResetCommand ; -export default class EStim2bProtocol implements DeviceProtocol> +export type EStim2bProtocolMessage = MessageWithResponse; + +export default class EStim2bProtocol implements DeviceProtocol { - public encode(command: Estim2bCommand): Buffer { - return Buffer.from(`${command}`, 'utf-8'); + public encode(message: InferMessage): Buffer { + return Buffer.from(`${message}`, 'utf-8'); } - public decode(data: Buffer): DecodeResult { + public decode(data: Buffer): DecodeResult InferResponse */> { return EStim2bProtocol.parseResponse(data.toString('utf-8')); } diff --git a/src/device/protocol/messageResponseHandler.ts b/src/device/protocol/messageResponseHandler.ts index 5be224d9..b9cd68f4 100644 --- a/src/device/protocol/messageResponseHandler.ts +++ b/src/device/protocol/messageResponseHandler.ts @@ -1,4 +1,4 @@ -import DeviceProtocol, { InferMR, InferResponse, MessageResponse } from './deviceProtocol.js'; +import DeviceProtocol, { InferMR, InferResponse, MessageWithOptionalResponse, MessageWithResponse } from './deviceProtocol.js'; import DeviceTransport from '../transport/deviceTransport.js'; import { clearTimeout } from 'node:timers'; import Logger from '../../logging/Logger.js'; @@ -12,18 +12,18 @@ type PendingEntry = { pendingSince: number, }; -export default class MessageResponseHandler

>> +export default class MessageResponseHandler

>> { private readonly protocol: P; private readonly transport: DeviceTransport; private readonly logger: Logger; private readonly pendingEntries: Set>> = new Set(); - public static create>( - protocol: DeviceProtocol, + public static create

>>( + protocol: P, transport: DeviceTransport, logger: Logger, - ): MessageResponseHandler> { + ): MessageResponseHandler

{ return new this(protocol, transport, logger); } @@ -74,12 +74,29 @@ export default class MessageResponseHandler

>( + msg: T + ): msg is Extract> { + return 'responseType' in msg; + } + public async send>( msg: MR, timeoutMs = 200 ): Promise> { const encodedMsg = this.protocol.encode(msg.message); + if (false === this.isMessageWithResponse(msg)) { + return new Promise>((resolve, reject) => { + console.log(`Sending message without expected response: ${encodedMsg.toString('utf-8')}`); + this.transport.send(encodedMsg).then(() => { + console.log(`Message sent without expected response: ${encodedMsg.toString('utf-8')}`); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + resolve(undefined as InferResponse); + }).catch(reject); + }); + } + return new Promise>((resolve, reject) => { const entry = { msg, diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlProtocol.ts b/src/device/protocol/slvCtrlPlus/slvCtrlProtocol.ts index 73d0fd6a..e6dc35f7 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlProtocol.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlProtocol.ts @@ -1,5 +1,5 @@ import { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; -import DeviceProtocol, { DecodeResult, MessageResponse } from '../deviceProtocol.js'; +import DeviceProtocol, { DecodeResult, InferMessage, InferResponse, MessageWithResponse } from '../deviceProtocol.js'; export type DeviceInfo = { deviceType: string, @@ -23,17 +23,19 @@ export type SlvCtrlProtocolResponse = { result: Result, } -export default abstract class SlvCtrlProtocol implements DeviceProtocol> +export type SlvCtrlProtocolMessage = MessageWithResponse; + +export default abstract class SlvCtrlProtocol implements DeviceProtocol { public static readonly EOF = '\n'; public static readonly transportTimeoutMs = 175; - public abstract encode(command: SlvCtrlProtocolCommand): Buffer; - public abstract decode(data: Buffer): DecodeResult; + public abstract encode(command: InferMessage): Buffer; + public abstract decode(data: Buffer): DecodeResult>; public abstract getAttributes(responseData: KeyValuePairs): SlvCtrlPlusDeviceAttributes; - public isResponseMatchingMessage(response: SlvCtrlProtocolResponse, message: MessageResponse): boolean { + public isResponseMatchingMessage(response: InferResponse, message: SlvCtrlProtocolMessage): boolean { return response.command === this.encode(message.message).toString(); } } diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts index cfc8f4d8..a2c718a9 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.ts @@ -9,10 +9,9 @@ import { Int } from '../../../util/numbers.js'; import { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; import SlvCtrlProtocol, { KeyValuePairs, Result, - SlvCtrlProtocolCommand, - SlvCtrlProtocolResponse + SlvCtrlProtocolMessage } from './slvCtrlProtocol.js'; -import { DecodeResult } from '../deviceProtocol.js'; +import { DecodeResult, InferMessage, InferResponse } from '../deviceProtocol.js'; type SetAttributeResponse = { command: string, @@ -30,7 +29,7 @@ export default class SlvCtrlProtocolLegacy extends SlvCtrlProtocol private static readonly attributeNameValueSeparator = ':'; - public encode(command: SlvCtrlProtocolCommand): Buffer { + public encode(command: InferMessage): Buffer { let commandToSend = command.command; let commandArgs; @@ -47,7 +46,7 @@ export default class SlvCtrlProtocolLegacy extends SlvCtrlProtocol return Buffer.from(`${commandToSend}${argsSuffixed}`, 'utf-8'); } - public decode(rawData: Buffer): DecodeResult { + public decode(rawData: Buffer): DecodeResult> { const [command, data, result] = rawData.toString('utf-8').split(';'); if (undefined === command || undefined === data) { diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.ts b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.ts index a2a15864..3c355e45 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.ts @@ -8,10 +8,9 @@ import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import { Int } from '../../../util/numbers.js'; import SlvCtrlProtocol, { KeyValuePairs, Result, - SlvCtrlProtocolCommand, - SlvCtrlProtocolResponse + SlvCtrlProtocolMessage } from './slvCtrlProtocol.js'; -import { DecodeResult } from '../deviceProtocol.js'; +import { DecodeResult, InferMessage, InferResponse } from '../deviceProtocol.js'; import { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; export default class SlvCtrlProtocolV1 extends SlvCtrlProtocol @@ -22,14 +21,14 @@ export default class SlvCtrlProtocolV1 extends SlvCtrlProtocol private static readonly keyValueSeparator = ':'; - public encode(command: SlvCtrlProtocolCommand): Buffer { + public encode(command: InferMessage): Buffer { const argsToSend = command.args.map(arg => (typeof arg === 'boolean'? Number(arg) : arg).toString()); const argsSuffixed = argsToSend.length > 0 ? ` ${argsToSend.join(' ')}` : ''; return Buffer.from(`${command.command}${argsSuffixed}`, 'utf-8'); } - public decode(data: Buffer): DecodeResult { + public decode(data: Buffer): DecodeResult> { return SlvCtrlProtocolV1.parseResponse(data.toString('utf-8')); } @@ -126,7 +125,7 @@ export default class SlvCtrlProtocolV1 extends SlvCtrlProtocol throw new Error(`Unknown attribute type: ${type}`); } - private static parseResponse(response: string): DecodeResult { + private static parseResponse(response: string): DecodeResult> { const segments = response.split(SlvCtrlProtocolV1.segmentSeparator); if (segments.length !== 3) { diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index d9271f59..efbba339 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -3,7 +3,6 @@ import { AttributeKeyOf, AttributeValueOf, DeviceAttributeOf } from '../../devic import Zc95MessageFactory, { MenuItem, MinMaxMenuItem, - MsgResponse, MultiChoiceMenuItem, PowerStatusMsgResponse, } from './zc95MessageFactory.js'; @@ -19,7 +18,7 @@ import typeDetect from 'type-detect'; import { AllOrNone } from '../../../types.js'; import { NoDeviceConfig } from '../../deviceConfig.js'; import PeripheralDevice from '../../peripheralDevice.js'; -import Zc95Protocol from './zc95Protocol.js'; +import Zc95Protocol, { MsgResponse } from './zc95Protocol.js'; import DeviceTransport from '../../transport/deviceTransport.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import Logger from '../../../logging/Logger.js'; diff --git a/src/device/protocol/zc95/zc95MessageFactory.ts b/src/device/protocol/zc95/zc95MessageFactory.ts index 6aa2c09d..c26fee72 100644 --- a/src/device/protocol/zc95/zc95MessageFactory.ts +++ b/src/device/protocol/zc95/zc95MessageFactory.ts @@ -1,22 +1,4 @@ -import { MessageResponse } from '../deviceProtocol.js'; - -type ResponseToKey = R extends { Type: infer T } ? T : never; - -export interface Msg -{ - Type: string; - MsgId: number; -} - -export interface ResponseIdentifier -{ - msgId: number, - type: ResponseToKey -} - -export interface MsgAndResponseIdentifier extends Omit, '__responseType'> { - responseIdentifier: ResponseIdentifier; -} +import Zc95Protocol, { Msg, MsgAndResponseIdentifier, MsgResponse } from './zc95Protocol.js'; export interface GetPatternDetailMsg extends Msg { @@ -103,14 +85,6 @@ export interface DeleteLuaScriptMsg extends Msg Index: number; } -export interface MsgResponse -{ - Type: string; - MsgId: number; - Result: 'OK' | 'ERROR' - Error?: string; -} - export interface AckMsgResponse extends MsgResponse { Type: 'Ack', } @@ -198,106 +172,87 @@ export default class Zc95MessageFactory public createGetPatterns(): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'GetPatterns', MsgId: msgId, }, - responseIdentifier: { - msgId: msgId, - type: 'PatternList', - }, - }; + 'PatternList' + ); } public createGetPatternDetails(patternId: number): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'GetPatternDetail', MsgId: msgId, Id: String(patternId) }, - responseIdentifier: { - msgId: msgId, - type: 'PatternDetail', - }, - }; + 'PatternDetail', + ); } public createPatternStart(patternId: number): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'PatternStart', MsgId: msgId, Index: patternId }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - }, - }; + 'Ack', + ); } public createPatternMinMaxChange( menuId: number, newValue: number ): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'PatternMinMaxChange', MsgId: msgId, MenuId: menuId, NewValue: newValue }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - } - }; + 'Ack' + ); } public createPatternMultiChoiceChange( menuId: number, choiceId: number ): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'PatternMultiChoiceChange', MsgId: msgId, MenuId: menuId, ChoiceId: choiceId, }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - }, - - }; + 'Ack' + ); } public createPatternSoftButton(pressed: boolean): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'PatternSoftButton', MsgId: msgId, Pressed: pressed ? 1 : 0 }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - } - }; + 'Ack' + ); } public createSetPower( chan1: number, chan2: number, chan3: number, chan4: number ): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'SetPower', MsgId: msgId, Chan1: chan1, @@ -305,113 +260,89 @@ export default class Zc95MessageFactory Chan3: chan3, Chan4: chan4 }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - } - }; + 'Ack' + ); } public createPatternStop(): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'PatternStop', MsgId: msgId, }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - }, - }; + 'Ack' + ); } public createGetVersionDetails(): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'GetVersion', MsgId: msgId, }, - responseIdentifier: { - msgId: msgId, - type: 'VersionDetails', - } - }; + 'VersionDetails' + ); } public createLuaStart(index: number): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'LuaStart', MsgId: msgId, Index: index }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - } - }; + 'Ack' + ); } public createLuaLine(lineNumber: number, text: string): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'LuaLine', MsgId: msgId, LineNumber: lineNumber, Text: text.trimEnd() }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - } - }; + 'Ack' + ); } public createLuaEnd(): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'LuaEnd', MsgId: msgId, }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - } - }; + 'Ack' + ); } public createGetLuaScripts(): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'GetLuaScripts', MsgId: msgId, }, - responseIdentifier: { - msgId: msgId, - type: 'LuaScripts', - } - }; + 'LuaScripts' + ); } public createDeleteLuaScript(index: number): MsgAndResponseIdentifier { const msgId = this.getNextMsgIndex(); - return { - message: { + return Zc95Protocol.createMessage( + { Type: 'DeleteLuaScript', MsgId: msgId, Index: index }, - responseIdentifier: { - msgId: msgId, - type: 'Ack', - } - }; + 'Ack' + ); } private getNextMsgIndex(): number { diff --git a/src/device/protocol/zc95/zc95Protocol.ts b/src/device/protocol/zc95/zc95Protocol.ts index f2000484..768dec34 100644 --- a/src/device/protocol/zc95/zc95Protocol.ts +++ b/src/device/protocol/zc95/zc95Protocol.ts @@ -1,18 +1,45 @@ -import DeviceProtocol, { DecodeResult, InferMessage, InferResponse } from '../deviceProtocol.js'; -import { Msg, MsgAndResponseIdentifier, MsgResponse } from './zc95MessageFactory.js'; +import DeviceProtocol, { DecodeResult, InferMessage, InferResponse, MessageWithResponse } from '../deviceProtocol.js'; import BaseError from 'modern-errors'; -export default class Zc95Protocol = MsgAndResponseIdentifier> implements DeviceProtocol +type ResponseToKey = R extends { Type: infer T } ? T : never; + +export interface Msg +{ + Type: string; + MsgId: number; +} + +export interface MsgResponse +{ + Type: string; + MsgId: number; + Result: 'OK' | 'ERROR' + Error?: string; +} + +export interface ResponseIdentifier +{ + msgId: number, + type: ResponseToKey +} + +export interface MsgAndResponseIdentifier extends MessageWithResponse { + responseIdentifier: ResponseIdentifier; +} + +export type Zc95ProtocolMessage = MsgAndResponseIdentifier; + +export default class Zc95Protocol implements DeviceProtocol { public static readonly STX = 0x02; public static readonly ETX = 0x03; public static readonly EOT = 0x04; - public encode(message: InferMessage): Buffer { + public encode(message: InferMessage): Buffer { return Buffer.from(JSON.stringify(message), 'utf-8'); } - public decode(data: Buffer): DecodeResult> { + public decode(data: Buffer): DecodeResult> { try { const jsonResponse = JSON.parse(data.toString('utf-8')); @@ -30,8 +57,24 @@ export default class Zc95Protocol, messageResponse: F): boolean { + public isResponseMatchingMessage(response: InferResponse, messageResponse: Zc95ProtocolMessage): boolean { return response.MsgId === messageResponse.responseIdentifier.msgId && response.Type === messageResponse.responseIdentifier.type; } + + public static createMessage( + message: M, + responseType: ResponseToKey + ): MsgAndResponseIdentifier { + const responseIdentifier: ResponseIdentifier = { + msgId: message.MsgId, + type: responseType, + }; + + return { + message, + responseType: undefined, + responseIdentifier, + }; + } } \ No newline at end of file diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index f5b01eed..12b5fa25 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -16,7 +16,9 @@ export default class BleUartDeviceTransport implements DeviceTransport uartRxCharacteristicUuid: string, uartTxCharacteristicUuid: string ): Promise { - await peripheral.connectAsync(); + if (peripheral.state === 'disconnected') { + await peripheral.connectAsync(); + } const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync( [/* AiroticDeviceProvider.UART_SERVICE_UUID */], @@ -47,7 +49,7 @@ export default class BleUartDeviceTransport implements DeviceTransport } public async send(data: Buffer): Promise { - await this.rx.writeAsync(data, false); + await this.rx.writeAsync(data, true); } public onReceive(dataProcessor: (data: Buffer) => void): void { @@ -64,7 +66,6 @@ export default class BleUartDeviceTransport implements DeviceTransport public async close(): Promise { await this.tx.unsubscribeAsync(); - await this.peripheral.disconnectAsync(); this.isConnected = false; for (const callback of this.onCloseSubscribers) { diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index b002afe0..ff4b2bbb 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -48,7 +48,7 @@ export default class BleObserver }; this.deviceManager.announceDetectedDevice(deviceInfo); - this.announcedDevices.set(peripheral.id, peripheral); + // this.announcedDevices.set(peripheral.id, peripheral); } private async observe(): Promise { diff --git a/src/index.ts b/src/index.ts index 420be8f9..46a87dbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,6 +111,8 @@ app.put('/settings', executeController(container, 'controller.settings.put')); app.get('/health', executeController(container, 'controller.health')); app.get('/version', executeController(container, 'controller.version')); + logger.error(`Example error`, new Error('This is an example error to test logging of stack traces and error contexts')); + // Whenever someone connects this gets executed io.on('connection', socket => { logger.debug(`Client connected: ${socket.id}`); diff --git a/src/util/async.ts b/src/util/async.ts index 7d8b9449..c0ef4673 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -80,3 +80,22 @@ export const setIntervalAsync = ( } }; } + +export const promiseWithTimeout = ( + promise: Promise, + timeoutMs: number, + timeoutMessage = `Promise timed out after ${timeoutMs}ms` +): Promise => { + let timeoutHandle: ReturnType; + + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(timeoutMessage)); + }, timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]).then((result) => { + clearTimeout(timeoutHandle); + return result; + }); +}; \ No newline at end of file diff --git a/src/util/createMapTransformFn.ts b/src/util/createMapTransformFn.ts index 983c2997..c6bedaaa 100644 --- a/src/util/createMapTransformFn.ts +++ b/src/util/createMapTransformFn.ts @@ -22,7 +22,6 @@ export default function createMapTransformFn(mapValueClass: ClassConstructor< }) .map(([k, v]) => { const transformedValue = isPrimitiveClass ? (mapValueClass as any)(v) : plainToInstance(mapValueClass, v, options); - console.log(options, k, transformedValue) return [k, transformedValue]; }) as [string, T][]; return new Map(transformedEntries); From d3959637021d2461bb4ee42c4a378951effb7a32 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 29 Mar 2026 15:06:56 +0200 Subject: [PATCH 12/42] wip, unfortunately bottle is stuck after first reconnect --- src/device/protocol/airotic/airoticDevice.ts | 3 + src/device/protocol/messageResponseHandler.ts | 5 +- src/device/transport/bleDeviceTransport.ts | 93 ++++++++++++++----- src/index.ts | 2 - 4 files changed, 76 insertions(+), 27 deletions(-) diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index f86c664e..9f39cae3 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -10,6 +10,7 @@ import BleDevice from '../../bleDevice.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import AiroticProtocol from './airtonicProtocol.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; +import { sleep } from '../../../util/async.js'; export type AiroticDeviceAttributes = { restColor: StrDeviceAttribute, @@ -47,6 +48,7 @@ export default class AiroticDevice extends BleDevice parseInt(c, 10))); await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); + await sleep(100); await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); return value; } @@ -54,6 +56,7 @@ export default class AiroticDevice extends BleDevice parseInt(c, 10))); await this.messageResponseHandler.send(AiroticProtocol.createSelectBreathInColorMessage()); + await sleep(100); await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); return value; } diff --git a/src/device/protocol/messageResponseHandler.ts b/src/device/protocol/messageResponseHandler.ts index b9cd68f4..0e3d7024 100644 --- a/src/device/protocol/messageResponseHandler.ts +++ b/src/device/protocol/messageResponseHandler.ts @@ -2,6 +2,7 @@ import DeviceProtocol, { InferMR, InferResponse, MessageWithOptionalResponse, Me import DeviceTransport from '../transport/deviceTransport.js'; import { clearTimeout } from 'node:timers'; import Logger from '../../logging/Logger.js'; +import { promiseWithTimeout } from '../../util/async.js'; type PendingEntry = { msg: MR; @@ -87,14 +88,14 @@ export default class MessageResponseHandler

>((resolve, reject) => { + return promiseWithTimeout(new Promise>((resolve, reject) => { console.log(`Sending message without expected response: ${encodedMsg.toString('utf-8')}`); this.transport.send(encodedMsg).then(() => { console.log(`Message sent without expected response: ${encodedMsg.toString('utf-8')}`); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions resolve(undefined as InferResponse); }).catch(reject); - }); + }), timeoutMs); } return new Promise>((resolve, reject) => { diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index 12b5fa25..93650460 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -1,46 +1,85 @@ import { Characteristic, Peripheral } from '@stoprocent/noble'; import DeviceTransport from './deviceTransport.js'; +import { asyncHandler } from '../../util/async.js'; export default class BleUartDeviceTransport implements DeviceTransport { private readonly peripheral: Peripheral; - private readonly rx: Characteristic; - private readonly tx: Characteristic; + private rx?: Characteristic; + private tx?: Characteristic; - private isConnected: boolean = true; + private readonly uartRxCharacteristicUuid: string; + private readonly uartTxCharacteristicUuid: string; + + private isConnected: boolean = false; + private isSubscribing: boolean = false; private onCloseSubscribers: (() => Promise)[] = []; + private onReceiveSubscribers: ((data: Buffer) => void)[] = []; public static async create( peripheral: Peripheral, uartRxCharacteristicUuid: string, uartTxCharacteristicUuid: string ): Promise { - if (peripheral.state === 'disconnected') { - await peripheral.connectAsync(); - } + const transport = new this(peripheral, uartRxCharacteristicUuid, uartTxCharacteristicUuid); + await transport.subscribe(); + return transport; + } - const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync( - [/* AiroticDeviceProvider.UART_SERVICE_UUID */], - [uartRxCharacteristicUuid, uartTxCharacteristicUuid], - ); + private constructor(peripheral: Peripheral, uartRxCharacteristicUuid: string, uartTxCharacteristicUuid: string) { + this.peripheral = peripheral; + this.uartRxCharacteristicUuid = uartRxCharacteristicUuid; + this.uartTxCharacteristicUuid = uartTxCharacteristicUuid; - const rx = characteristics.find((c) => c.uuid === uartRxCharacteristicUuid); - const tx = characteristics.find((c) => c.uuid === uartTxCharacteristicUuid); + this.peripheral.on('connect', asyncHandler(async () => await this.subscribe(), console.error)); + this.peripheral.on('disconnect', () => { + this.isConnected = false; + }); + } - if (!rx || !tx) { - throw new Error('Missing UART RX/TX characteristics on device.'); + private async subscribe(): Promise { + if (this.isSubscribing) { + return; } - await tx.subscribeAsync(); + this.isSubscribing = true; - return await new this(peripheral, rx, tx); - } + try { + if (this.peripheral.state === 'disconnected') { + await this.peripheral.connectAsync(); + } - private constructor(peripheral: Peripheral, rx: Characteristic, tx: Characteristic) { - this.peripheral = peripheral; - this.rx = rx; - this.tx = tx; + const { characteristics } = await this.peripheral.discoverSomeServicesAndCharacteristicsAsync( + [/* AiroticDeviceProvider.UART_SERVICE_UUID */], + [this.uartRxCharacteristicUuid, this.uartTxCharacteristicUuid], + ); + + const rx = characteristics.find((c) => c.uuid === this.uartRxCharacteristicUuid); + const tx = characteristics.find((c) => c.uuid === this.uartTxCharacteristicUuid); + + if (!rx || !tx) { + throw new Error('Missing UART RX/TX characteristics on device.'); + } + + this.rx = rx; + + for (const subscriber of this.onReceiveSubscribers) { + this.tx?.removeListener('data', subscriber); + } + + this.tx = tx; + + await this.tx.subscribeAsync(); + + for (const subscriber of this.onReceiveSubscribers) { + this.tx.on('data', subscriber); + } + + this.isConnected = true; + } finally { + this.isSubscribing = false; + } } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -49,11 +88,13 @@ export default class BleUartDeviceTransport implements DeviceTransport } public async send(data: Buffer): Promise { + if (!this.rx) { throw new Error('RX characteristic not initialized'); } await this.rx.writeAsync(data, true); } public onReceive(dataProcessor: (data: Buffer) => void): void { - this.tx.on('data', dataProcessor); + this.onReceiveSubscribers.push(dataProcessor); + this.tx?.on('data', dataProcessor); } public onClose(callback: () => Promise): void { @@ -65,7 +106,13 @@ export default class BleUartDeviceTransport implements DeviceTransport } public async close(): Promise { - await this.tx.unsubscribeAsync(); + if (this.tx) { + for (const subscriber of this.onReceiveSubscribers) { + this.tx.removeListener('data', subscriber); + } + await this.tx.unsubscribeAsync(); + } + this.isConnected = false; for (const callback of this.onCloseSubscribers) { diff --git a/src/index.ts b/src/index.ts index 46a87dbd..420be8f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,8 +111,6 @@ app.put('/settings', executeController(container, 'controller.settings.put')); app.get('/health', executeController(container, 'controller.health')); app.get('/version', executeController(container, 'controller.version')); - logger.error(`Example error`, new Error('This is an example error to test logging of stack traces and error contexts')); - // Whenever someone connects this gets executed io.on('connection', socket => { logger.debug(`Client connected: ${socket.id}`); From da3cda6baa17bdfa56b398283dc4624e36294933 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 29 Mar 2026 18:14:05 +0200 Subject: [PATCH 13/42] wip, everything working --- src/device/bleDevice.ts | 10 ++++++++-- .../protocol/airotic/airoticDeviceProvider.ts | 16 ++++++++++++---- src/device/protocol/messageResponseHandler.ts | 2 -- src/device/transport/bleDeviceTransport.ts | 19 ++++++++++++++----- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 12644c39..9ceed903 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -49,8 +49,12 @@ export default abstract class BleDevice< async () => { this.logger.info(`BLE Device ${this.deviceId} disconnected, trying to reconnect`); try { - await promiseWithTimeout(peripheral.connectAsync(), 2000); - this.logger.info(`BLE Device ${this.deviceId} reconnected successfully`); + if (peripheral.state !== 'connected') { + await promiseWithTimeout(peripheral.connectAsync(), 2000); + this.logger.info(`BLE Device ${this.deviceId} reconnected successfully`); + } else { + this.logger.warn(`BLE Device ${this.deviceId} is not in disconnected state, current state: ${peripheral.state}`); + } } catch (e) { const error = BaseError.normalize(e); this.logger.warn(`Error reconnecting to device ${this.deviceId}: ${error.message}`); @@ -92,5 +96,7 @@ export default abstract class BleDevice< } else if (this.peripheral.state === 'connecting') { this.peripheral.cancelConnect(); } + + // this.peripheral.removeAllListeners(); THIS IS THE SOLUTION, BUT UGLY } } \ No newline at end of file diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 07b92c46..7eb88ebf 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -4,7 +4,7 @@ import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../../deviceManag import DeviceProvider from '../../provider/deviceProvider.js'; import AiroticDevice from './airoticDevice.js'; import Logger from '../../../logging/Logger.js'; -import { asyncHandler } from '../../../util/async.js'; +import { asyncHandler, promiseWithTimeout } from '../../../util/async.js'; import { logError } from '../../../util/error.js'; import { BleDeviceInfo } from '../../transport/bleObserver.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; @@ -17,6 +17,7 @@ import Settings from '../../../settings/settings.js'; import KnownDevice from '../../../settings/knownDevice.js'; import { DeviceId } from '../../deviceId.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; +import { DeviceEvent } from '../../device.js'; export default class AiroticDeviceProvider extends DeviceProvider { @@ -59,11 +60,11 @@ export default class AiroticDeviceProvider extends DeviceProvider await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - const transport = await BleUartDeviceTransport.create( + const transport = await promiseWithTimeout(BleUartDeviceTransport.create( deviceInfo.peripheral, AiroticDeviceProvider.UART_RX_CHAR_UUID, AiroticDeviceProvider.UART_TX_CHAR_UUID - ); + ), 2000); this.logger.debug(`Connected to device: ${deviceInfo.id}`); @@ -74,7 +75,14 @@ export default class AiroticDeviceProvider extends DeviceProvider const handshakeResult = await this.doHandshake(messageResponseHandler); if (handshakeResult) { - this.deviceManager.addDevice(this.createDevice(deviceInfo, deviceInfo.peripheral, messageResponseHandler, protocol)); + const device = this.createDevice(deviceInfo, deviceInfo.peripheral, messageResponseHandler, protocol); + + device.on(DeviceEvent.deviceDisconnected, asyncHandler( + async () => await transport.close(), + (e: unknown) => logError(this.logger, `Error in device disconnected handler for device ${device.getDeviceId}`, e)) + ); + + this.deviceManager.addDevice(device); this.deviceManager.claimDetectedDevice(deviceInfo.id); } else { diff --git a/src/device/protocol/messageResponseHandler.ts b/src/device/protocol/messageResponseHandler.ts index 0e3d7024..f66618bb 100644 --- a/src/device/protocol/messageResponseHandler.ts +++ b/src/device/protocol/messageResponseHandler.ts @@ -89,9 +89,7 @@ export default class MessageResponseHandler

>((resolve, reject) => { - console.log(`Sending message without expected response: ${encodedMsg.toString('utf-8')}`); this.transport.send(encodedMsg).then(() => { - console.log(`Message sent without expected response: ${encodedMsg.toString('utf-8')}`); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions resolve(undefined as InferResponse); }).catch(reject); diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index 93650460..d11f525b 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -17,6 +17,9 @@ export default class BleUartDeviceTransport implements DeviceTransport private onCloseSubscribers: (() => Promise)[] = []; private onReceiveSubscribers: ((data: Buffer) => void)[] = []; + private readonly connectHandler: (err: Error) => void; + private readonly disconnectHandler: (err: Error) => void; + public static async create( peripheral: Peripheral, uartRxCharacteristicUuid: string, @@ -32,14 +35,15 @@ export default class BleUartDeviceTransport implements DeviceTransport this.uartRxCharacteristicUuid = uartRxCharacteristicUuid; this.uartTxCharacteristicUuid = uartTxCharacteristicUuid; - this.peripheral.on('connect', asyncHandler(async () => await this.subscribe(), console.error)); - this.peripheral.on('disconnect', () => { - this.isConnected = false; - }); + this.connectHandler = asyncHandler(async (err: Error) => { if (undefined !== err) return; await this.subscribe() }, console.error); + this.disconnectHandler = (): void => { this.isConnected = false; }; + + this.peripheral.on('connect', this.connectHandler); + this.peripheral.on('disconnect', this.disconnectHandler); } private async subscribe(): Promise { - if (this.isSubscribing) { + if (this.isSubscribing || this.isConnected) { return; } @@ -48,6 +52,8 @@ export default class BleUartDeviceTransport implements DeviceTransport try { if (this.peripheral.state === 'disconnected') { await this.peripheral.connectAsync(); + } else if (this.peripheral.state !== 'connected') { + throw new Error(`Peripheral in unexpected state: ${this.peripheral.state}`); } const { characteristics } = await this.peripheral.discoverSomeServicesAndCharacteristicsAsync( @@ -106,6 +112,9 @@ export default class BleUartDeviceTransport implements DeviceTransport } public async close(): Promise { + this.peripheral.off('connect', this.connectHandler); + this.peripheral.off('disconnect', this.disconnectHandler); + if (this.tx) { for (const subscriber of this.onReceiveSubscribers) { this.tx.removeListener('data', subscriber); From 66eecfd631f49ae7a4dbc067ba8c71ad86440c4b Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 29 Mar 2026 20:29:22 +0200 Subject: [PATCH 14/42] Some improvements? --- src/device/bleDevice.ts | 2 +- src/device/genericDeviceUpdater.ts | 10 +++++++++- src/device/protocol/airotic/airoticDevice.ts | 3 +++ .../protocol/airotic/airoticDeviceProvider.ts | 4 ++-- src/device/protocol/messageResponseHandler.ts | 17 +++++++++++------ 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 9ceed903..04bcd4da 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -50,7 +50,7 @@ export default abstract class BleDevice< this.logger.info(`BLE Device ${this.deviceId} disconnected, trying to reconnect`); try { if (peripheral.state !== 'connected') { - await promiseWithTimeout(peripheral.connectAsync(), 2000); + await promiseWithTimeout(peripheral.connectAsync(), 5000, `Timed out while reconnecting to device ${this.deviceId}`); this.logger.info(`BLE Device ${this.deviceId} reconnected successfully`); } else { this.logger.warn(`BLE Device ${this.deviceId} is not in disconnected state, current state: ${peripheral.state}`); diff --git a/src/device/genericDeviceUpdater.ts b/src/device/genericDeviceUpdater.ts index c969117b..9ab32183 100644 --- a/src/device/genericDeviceUpdater.ts +++ b/src/device/genericDeviceUpdater.ts @@ -9,6 +9,8 @@ export default class GenericDeviceUpdater extends AbstractDeviceUpdater { private logger: Logger; + private readonly failedMessageCountPerDevice: Map = new Map(); + public constructor(serializer: PlainToClassSerializer, logger: Logger) { super(serializer); @@ -27,10 +29,16 @@ export default class GenericDeviceUpdater extends AbstractDeviceUpdater const deviceLogMsg = `device: ${device.getDeviceId} -> ${attrKey} ${attrStr}`; try { - await device.setAttribute(attrKey, attrStr) + await device.setAttribute(attrKey, attrStr); + this.failedMessageCountPerDevice.set(device.getDeviceId, 0); this.logger.info(`${deviceLogMsg} -> done`); } catch(e: unknown) { + this.failedMessageCountPerDevice.set(device.getDeviceId, (this.failedMessageCountPerDevice.get(device.getDeviceId) ?? 0) + 1); logError(this.logger, `${deviceLogMsg} -> failed`, e); + + if ((this.failedMessageCountPerDevice.get(device.getDeviceId) ?? 0) > 10) { + this.logger.warn(`Device ${device.getDeviceId} has more than 10 failed update attempts, disconnecting it to prevent further issues`); + } } } } diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 9f39cae3..d3420539 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -71,7 +71,10 @@ export default class AiroticDevice extends BleDevice>> = new Set(); + private readonly timeoutMs: number; public static create

>>( protocol: P, transport: DeviceTransport, logger: Logger, + timeoutMs: number = 200, ): MessageResponseHandler

{ - return new this(protocol, transport, logger); + return new this(protocol, transport, logger, timeoutMs); } private constructor( protocol: P, transport: DeviceTransport, logger: Logger, + timeoutMs: number, ) { this.protocol = protocol; this.transport = transport; this.logger = logger.child({ name: `${MessageResponseHandler.name}.${transport.getDeviceIdentifier()}` }); + this.timeoutMs = timeoutMs; transport.onReceive(data => this.onResponse(data)); transport.onClose(async () => { @@ -83,9 +87,10 @@ export default class MessageResponseHandler

>( msg: MR, - timeoutMs = 200 + timeoutMs?: number, ): Promise> { const encodedMsg = this.protocol.encode(msg.message); + const realTimeoutMs = timeoutMs ?? this.timeoutMs; if (false === this.isMessageWithResponse(msg)) { return promiseWithTimeout(new Promise>((resolve, reject) => { @@ -93,7 +98,7 @@ export default class MessageResponseHandler

); }).catch(reject); - }), timeoutMs); + }), realTimeoutMs, `Message ${encodedMsg.toString()} timed out after ${realTimeoutMs}ms`); } return new Promise>((resolve, reject) => { @@ -104,10 +109,10 @@ export default class MessageResponseHandler

{ this.pendingEntries.delete(entry); reject(new Error( - `Timed out (>${timeoutMs}ms) waiting for response for message: ${encodedMsg.toString()}` + `Timed out (>${realTimeoutMs}ms) waiting for response for message: ${encodedMsg.toString()}` )); - }, timeoutMs), - timeoutMs, + }, realTimeoutMs), + timeoutMs: realTimeoutMs, pendingSince: Date.now(), }; From 9a183b4c9da6cc96ea02235495ef273f1ab80b3a Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 30 Mar 2026 21:32:46 +0200 Subject: [PATCH 15/42] Review feedback --- src/controller/patchDeviceController.ts | 3 +- src/device/bleDevice.ts | 20 +-- src/device/protocol/airotic/airoticDevice.ts | 17 ++- .../protocol/airotic/airoticDeviceProvider.ts | 20 ++- src/device/protocol/zc95/zc95Device.ts | 2 +- src/device/transport/bleDeviceTransport.ts | 12 +- src/device/transport/bleObserver.ts | 14 +- src/index.ts | 4 +- src/util/async.ts | 121 +++++++++--------- 9 files changed, 131 insertions(+), 82 deletions(-) diff --git a/src/controller/patchDeviceController.ts b/src/controller/patchDeviceController.ts index 403a952a..15eef4f6 100644 --- a/src/controller/patchDeviceController.ts +++ b/src/controller/patchDeviceController.ts @@ -33,7 +33,8 @@ export default class PatchDeviceController implements ControllerInterface await this.deviceUpdater.update(device, req.body); } catch (e: unknown) { const error = BaseError.normalize(e); - res.send(error.message).sendStatus(500); + res.status(500).send(error.message); + return; } res.sendStatus(202); diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 04bcd4da..6505789b 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -16,6 +16,9 @@ export default abstract class BleDevice< { private readonly peripheral: Peripheral; + private readonly rssiInterval: NodeJS.Timeout; + private readonly reconnectHandler: () => void; + @Expose() private rssi: number; @@ -40,17 +43,17 @@ export default abstract class BleDevice< this.peripheral = peripheral; this.rssi = peripheral.rssi; - const rssiInterval = setInterval(asyncHandler( + this.rssiInterval = setInterval(asyncHandler( async () => await this.requestRssiUpdate(), (e: unknown) => logError(this.logger,`Error during RSSI update for device ${this.deviceId}`, e) ), 5000); - const reconnectHandler = asyncHandler( + this.reconnectHandler = asyncHandler( async () => { this.logger.info(`BLE Device ${this.deviceId} disconnected, trying to reconnect`); try { if (peripheral.state !== 'connected') { - await promiseWithTimeout(peripheral.connectAsync(), 5000, `Timed out while reconnecting to device ${this.deviceId}`); + await promiseWithTimeout(peripheral.connectAsync(), 3000, `Timed out (>3s) while reconnecting to device ${this.deviceId}`); this.logger.info(`BLE Device ${this.deviceId} reconnected successfully`); } else { this.logger.warn(`BLE Device ${this.deviceId} is not in disconnected state, current state: ${peripheral.state}`); @@ -58,15 +61,13 @@ export default abstract class BleDevice< } catch (e) { const error = BaseError.normalize(e); this.logger.warn(`Error reconnecting to device ${this.deviceId}: ${error.message}`); - peripheral.off('disconnect', reconnectHandler); - clearInterval(rssiInterval); await this.close(); } }, (err: unknown) => logError(this.logger, `Error in reconnect handler for device ${this.deviceId}`, err) ); - peripheral.on('disconnect', reconnectHandler); + this.peripheral.on('disconnect', this.reconnectHandler); } private async requestRssiUpdate(): Promise { @@ -75,7 +76,7 @@ export default abstract class BleDevice< } try { - const rssi = await promiseWithTimeout(this.peripheral.updateRssiAsync(), 1000); + const rssi = await promiseWithTimeout(this.peripheral.updateRssiAsync(), 750, `Timed out (>750ms) while updating RSSI for device ${this.deviceId}`); this.logger.debug(`Received RSSI update for device ${this.deviceId}: ${rssi}`); this.rssi = rssi; @@ -89,7 +90,7 @@ export default abstract class BleDevice< protected override async doClose(): Promise { if (this.peripheral.state === 'connected') { try { - await promiseWithTimeout(this.peripheral.disconnectAsync(), 1000); + await promiseWithTimeout(this.peripheral.disconnectAsync(), 750, `Timed out (>750ms) while disconnecting from device ${this.deviceId}`); } catch (error) { logError(this.logger, `Error disconnecting device ${this.deviceId}`, error); } @@ -97,6 +98,7 @@ export default abstract class BleDevice< this.peripheral.cancelConnect(); } - // this.peripheral.removeAllListeners(); THIS IS THE SOLUTION, BUT UGLY + clearInterval(this.rssiInterval); + this.peripheral.off('disconnect', this.reconnectHandler); } } \ No newline at end of file diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index d3420539..1a65daa1 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -46,7 +46,7 @@ export default class AiroticDevice extends BleDevice parseInt(c, 10))); + const { r, g, b } = this.parseColor(value); await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); await sleep(100); await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); @@ -54,7 +54,7 @@ export default class AiroticDevice extends BleDevice parseInt(c, 10))); + const { r, g, b } = this.parseColor(value); await this.messageResponseHandler.send(AiroticProtocol.createSelectBreathInColorMessage()); await sleep(100); await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); @@ -80,4 +80,17 @@ export default class AiroticDevice extends BleDevice { + const channelNumber = parseInt(c, 10); + + if (isNaN(channelNumber) || channelNumber < 0 || channelNumber > 255) { + throw new Error(`Invalid color channel value: ${c}`); + } + + return channelNumber; + })); + return { r, g, b }; + } } diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 656b7243..732efc0e 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -58,7 +58,12 @@ export default class AiroticDeviceProvider extends DeviceProvider try { this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + + if (!acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); + return; + } const transport = await promiseWithTimeout(BleUartDeviceTransport.create( deviceInfo.peripheral, @@ -87,10 +92,23 @@ export default class AiroticDeviceProvider extends DeviceProvider this.deviceManager.claimDetectedDevice(deviceInfo.id); } else { this.deviceManager.releaseDetectedDevice(deviceInfo.id); + await transport.close(); + await this.disconnectDevice(deviceInfo.peripheral); } } catch (e: unknown) { logError(this.logger, `Error while connecting to device`, e); this.deviceManager.releaseDetectedDevice(deviceInfo.id); + await this.disconnectDevice(deviceInfo.peripheral); + } + } + + private async disconnectDevice(peripheral: Peripheral): Promise { + if (peripheral.state === 'connected') { + await promiseWithTimeout( + peripheral.disconnectAsync(), + 2000, + `Timed out while disconnecting from device ${peripheral.id}` + ); } } diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index efbba339..543292ef 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -133,7 +133,7 @@ export default class Zc95Device extends PeripheralDevice { if (undefined !== err) return; await this.subscribe() }, console.error); + this.connectHandler = asyncHandler(async (err: Error) => { if (null !== err) { return; } await this.subscribe() }, console.error); this.disconnectHandler = (): void => { this.isConnected = false; }; this.peripheral.on('connect', this.connectHandler); @@ -94,8 +94,14 @@ export default class BleUartDeviceTransport implements DeviceTransport } public async send(data: Buffer): Promise { - if (!this.rx) { throw new Error('RX characteristic not initialized'); } - await this.rx.writeAsync(data, true); + console.log({data, isConnected: this.isConnected, isSubscribing: this.isSubscribing, peripheralState: this.peripheral.state}); + /* if (this.peripheral.state === 'connected' && !this.isConnected && !this.isSubscribing) { + await this.subscribe(); + }*/ + if (!this.isConnected || !this.rx) { + throw new Error('Transport not connected'); + } + await this.rx.writeAsync(data, false); } public onReceive(dataProcessor: (data: Buffer) => void): void { diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index ff4b2bbb..66789cf8 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -3,6 +3,7 @@ import Logger from '../../logging/Logger.js'; import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; +import { asyncHandler } from '../../util/async.js'; export type BleDeviceInfo = DeviceInfo & { peripheral: Peripheral; @@ -31,6 +32,17 @@ export default class BleObserver { noble.on('discover', this.onDiscover.bind(this)); + noble.on('stateChange', asyncHandler( + async (state) => { + if (state === 'poweredOn') { + await this.observe(); + } + }, + (err: unknown) => logError(this.logger, 'Error in stateChange handler', err) + )); + + noble.on('scanStop', () => { this.logger.info('Noble scanning stopped'); }); + await this.observe(); } @@ -55,7 +67,7 @@ export default class BleObserver try { // Wait for Adapter poweredOn state await noble.waitForPoweredOnAsync(); - // Start scanning first + await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true); this.logger.info('Looking for BLE UART devices'); diff --git a/src/index.ts b/src/index.ts index 420be8f9..0b87c8cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,8 +128,8 @@ const serializer = container.get('serializer.classToPlain'); const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); -void serialPortObserver.init(); -void bleObserver.init(); +void serialPortObserver.init().catch(e => logError(logger, 'Initializing serial port observer failed', e)); +void bleObserver.init().catch(e => logError(logger, 'Initializing BLE observer failed', e)); deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); diff --git a/src/util/async.ts b/src/util/async.ts index c0ef4673..8e00d6dd 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -1,9 +1,9 @@ export const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); export const setImmediateInterval = ( - callback: (...args: TArgs) => void, - delay?: number, - ...args: TArgs + callback: (...args: TArgs) => void, + delay?: number, + ...args: TArgs ): NodeJS.Timeout => { callback(...args); return setInterval(callback, delay, ...args); @@ -17,85 +17,82 @@ export type IntervalAsyncOptions = { } export const asyncHandler = ( - fn: (...args: TArgs) => Promise, - onError: (err: unknown) => void + fn: (...args: TArgs) => Promise, + onError: (err: unknown) => void ): (...args: TArgs) => void => { - return (...args: TArgs): void => { - fn(...args).catch(onError); - }; + return (...args: TArgs): void => { + fn(...args).catch(onError); + }; }; export type IntervalAsync = { clear: () => void }; export const setIntervalAsync = ( - fn: (...args: TArgs) => Promise, - options: IntervalAsyncOptions, - ...args: TArgs + fn: (...args: TArgs) => Promise, + options: IntervalAsyncOptions, + ...args: TArgs ): IntervalAsync => { - let stopped = false; - let timer: ReturnType | undefined; + let stopped = false; + let timer: ReturnType | undefined; - const loop = async (): Promise => { - const promises = [fn(...args)]; + const loop = async (): Promise => { + const promises = [fn(...args)]; - let timeoutHandle: ReturnType | undefined; + let timeoutHandle: ReturnType | undefined; - if (undefined !== options.timeoutMs) { - promises.push(new Promise((_, reject) => - timeoutHandle = setTimeout(() => { - reject(new Error(`Interval function timed out (>${options.timeoutMs}ms)`)); - }, options.timeoutMs)) - ); - } + if (undefined !== options.timeoutMs) { + promises.push(new Promise((_, reject) => + timeoutHandle = setTimeout(() => { + reject(new Error(`Interval function timed out (>${options.timeoutMs}ms)`)); + }, options.timeoutMs)) + ); + } + + try { + await Promise.race(promises); + } catch (err) { + if (options.onError) { + options.onError(err); + } else { + throw err; + } + } finally { + clearTimeout(timeoutHandle); - try { - await Promise.race(promises); - } catch (err) { - if (options.onError) { - options.onError(err); - } else { - throw err; - } - } finally { - clearTimeout(timeoutHandle); + if (!stopped) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + timer = setTimeout(loop, options.intervalMs); + } + } + }; - if (!stopped) { + if (options.runImmediately ?? true) { + void loop(); + } else { // eslint-disable-next-line @typescript-eslint/no-misused-promises timer = setTimeout(loop, options.intervalMs); - } } - }; - if (options.runImmediately ?? true) { - void loop(); - } else { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - timer = setTimeout(loop, options.intervalMs); - } - - return { - clear: (): void => { - stopped = true; - if (timer) clearTimeout(timer); - } - }; + return { + clear: (): void => { + stopped = true; + if (timer) clearTimeout(timer); + } + }; } export const promiseWithTimeout = ( - promise: Promise, - timeoutMs: number, - timeoutMessage = `Promise timed out after ${timeoutMs}ms` + promise: Promise, + timeoutMs: number, + timeoutMessage = `Promise timed out after ${timeoutMs}ms` ): Promise => { - let timeoutHandle: ReturnType; + let timeoutHandle: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - reject(new Error(timeoutMessage)); - }, timeoutMs); - }); + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); - return Promise.race([promise, timeoutPromise]).then((result) => { - clearTimeout(timeoutHandle); - return result; - }); + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timeoutHandle); + }); }; \ No newline at end of file From ae34731374ebff8001aec321d884b7074f9a38cf Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 30 Mar 2026 21:35:39 +0200 Subject: [PATCH 16/42] Build fine, tests still failing --- src/device/protocol/airotic/airoticDevice.ts | 2 -- src/device/protocol/airotic/airoticDeviceProvider.ts | 5 ++--- src/device/transport/bleDeviceTransport.ts | 4 ---- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 1a65daa1..9f5c88fb 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -43,8 +43,6 @@ export default class AiroticDevice extends BleDevice, V extends AttributeValueOf>(attributeName: K, value: V): Promise { - console.log(`Setting attribute ${attributeName} to value ${value}`); - if (attributeName === 'restColor' && value !== null && typeof value === 'string') { const { r, g, b } = this.parseColor(value); await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 732efc0e..95354860 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -80,7 +80,7 @@ export default class AiroticDeviceProvider extends DeviceProvider const handshakeResult = await this.doHandshake(messageResponseHandler); if (handshakeResult) { - const device = this.createDevice(deviceInfo, deviceInfo.peripheral, messageResponseHandler, protocol); + const device = this.createDevice(deviceInfo, deviceInfo.peripheral, messageResponseHandler); device.on(DeviceEvent.deviceDisconnected, asyncHandler( async () => await transport.close(), @@ -115,8 +115,7 @@ export default class AiroticDeviceProvider extends DeviceProvider private createDevice( deviceInfo: BleDeviceInfo, peripheral: Peripheral, - messageResponseHandler: MessageResponseHandler, - protocol: AiroticProtocol + messageResponseHandler: MessageResponseHandler ): AiroticDevice { const knownDevice = this.createKnownDevice(deviceInfo.id, deviceInfo.peripheral.advertisement.localName ?? `Airotic ${deviceInfo.id}`, AiroticDeviceProvider.providerName); diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index 560384bc..1ec14bc3 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -94,10 +94,6 @@ export default class BleUartDeviceTransport implements DeviceTransport } public async send(data: Buffer): Promise { - console.log({data, isConnected: this.isConnected, isSubscribing: this.isSubscribing, peripheralState: this.peripheral.state}); - /* if (this.peripheral.state === 'connected' && !this.isConnected && !this.isSubscribing) { - await this.subscribe(); - }*/ if (!this.isConnected || !this.rx) { throw new Error('Transport not connected'); } From cfb17a424b5a3a9496b0e7b288d1f54c0de8f1a8 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 31 Mar 2026 07:30:44 +0200 Subject: [PATCH 17/42] Fix tests --- tests/unit/device/deviceManager.spec.ts | 9 +++++++++ .../protocol/buttplugIo/buttplugIoDevice.spec.ts | 15 +++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index 3f8ac6ef..c67d37ad 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -11,7 +11,9 @@ describe('deviceManager', () => { it('it adds device to managed devices and emits an event', async () => { const mockedDeviceManagerEventEmitter = mock(); + const mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, new Map(), mockedLogger); @@ -29,6 +31,7 @@ describe('deviceManager', () => { expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(1); expect(mockedDeviceManagerEventEmitter.emit).toBeCalledWith(DeviceManagerEvent.deviceConnected, device); + expect(mockedLogger.child).toBeCalledWith({ name: DeviceManager.name }); }); @@ -38,7 +41,9 @@ describe('deviceManager', () => { const device = new TestDevice('foo', 'Foo', new Date(), false, new EventEmitter()); const mockedDeviceManagerEventEmitter = mock(); + const mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedLogger); @@ -50,6 +55,7 @@ describe('deviceManager', () => { expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(2); expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(1, DeviceManagerEvent.deviceConnected, device); expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(2, DeviceManagerEvent.deviceRefreshed, device); + expect(mockedLogger.child).toBeCalledWith({ name: DeviceManager.name }); mockClear(mockedDeviceManagerEventEmitter); }); @@ -60,7 +66,9 @@ describe('deviceManager', () => { const device = new TestDevice('foo', 'Foo', new Date(), false, new EventEmitter()); const mockedDeviceManagerEventEmitter = mock(); + const mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedLogger); @@ -74,5 +82,6 @@ describe('deviceManager', () => { expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(2); expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(1, DeviceManagerEvent.deviceConnected, device); expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(2, DeviceManagerEvent.deviceDisconnected, device); + expect(mockedLogger.child).toBeCalledWith({ name: DeviceManager.name }); }); }); diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index 05a075f5..c87a9c98 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -9,19 +9,22 @@ import {DeviceAttributeModifier} from "../../../../../src/device/attribute/devic import {Int} from "../../../../../src/util/numbers.js"; import {describe, it, expect} from "vitest"; import {mock} from "vitest-mock-extended"; +import { EventEmitter } from 'stream'; +import { DeviceId } from '../../../../../src/device/deviceId.js'; describe('ButtplugIoDevice', () => { function createDevice(buttplugDeviceMock: ButtplugClientDevice, attrs: ButtplugIoDeviceAttributes): ButtplugIoDevice { return new ButtplugIoDevice( - 'device-id', + DeviceId.fromPath('device-id'), 'device name', 'device model', 'buttplugIo', new Date(), buttplugDeviceMock, attrs, + new EventEmitter(), ); } @@ -31,7 +34,7 @@ describe('ButtplugIoDevice', () => { const buttplugDeviceMock = mock(); const device = createDevice(buttplugDeviceMock, {}); - const attrName = 'bool' as ButtplugIoDeviceAttributeKey; + const attrName: ButtplugIoDeviceAttributeKey = 'Vibrate-1'; // Act const result = expect(device.setAttribute(attrName, false)); @@ -44,7 +47,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); - const boolAttrKey = 'bool-1' as ButtplugIoDeviceAttributeKey; + const boolAttrKey: ButtplugIoDeviceAttributeKey = 'Rotate-1'; const boolAttr = BoolDeviceAttribute.create(boolAttrKey, undefined, DeviceAttributeModifier.readWrite); @@ -60,7 +63,7 @@ describe('ButtplugIoDevice', () => { // Assert expect(buttplugDeviceMock.scalar).toHaveBeenCalledTimes(1); - expect(buttplugDeviceMock.scalar).toHaveBeenCalledWith({ActuatorType: 'bool', Index: 1, Scalar: 0}); + expect(buttplugDeviceMock.scalar).toHaveBeenCalledWith({ActuatorType: 'Rotate', Index: 1, Scalar: 0}); }); it('it updates device data and calls buttplugClientDevice on setting range attribute', async () => { @@ -68,7 +71,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); - const rangeAttrName = 'range-2' as ButtplugIoDeviceAttributeKey; + const rangeAttrName: ButtplugIoDeviceAttributeKey = 'Vibrate-2'; const rangeAttr = IntRangeDeviceAttribute.create( rangeAttrName, undefined, @@ -94,7 +97,7 @@ describe('ButtplugIoDevice', () => { // Assert expect(buttplugDeviceMock.scalar).toHaveBeenCalledTimes(1); expect(buttplugDeviceMock.scalar).toHaveBeenCalledWith({ - ActuatorType: 'range', + ActuatorType: 'Vibrate', Index: 2, Scalar: newValue/rangeAttr.max }); From 3f1be9aa96af0015b4befedadb62bace173f3eca Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 1 Apr 2026 07:05:39 +0200 Subject: [PATCH 18/42] Fix review commens 2 --- src/device/genericDeviceUpdater.ts | 19 ++++++++++++++----- src/device/protocol/airotic/airoticDevice.ts | 11 +++++++++-- src/device/protocol/zc95/zc95Device.ts | 2 +- src/device/transport/bleObserver.ts | 1 - .../buttplugIo/buttplugIoDevice.spec.ts | 2 +- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/device/genericDeviceUpdater.ts b/src/device/genericDeviceUpdater.ts index 9ab32183..700c4945 100644 --- a/src/device/genericDeviceUpdater.ts +++ b/src/device/genericDeviceUpdater.ts @@ -18,6 +18,8 @@ export default class GenericDeviceUpdater extends AbstractDeviceUpdater } public async update(device: Device, rawData: DeviceData): Promise { + let hadFailure = false; + // Queue update for later to not reject if device is busy for (const attrKey of getTypedKeys(rawData)) { if (undefined === await device.getAttribute(attrKey)) { @@ -30,16 +32,23 @@ export default class GenericDeviceUpdater extends AbstractDeviceUpdater try { await device.setAttribute(attrKey, attrStr); - this.failedMessageCountPerDevice.set(device.getDeviceId, 0); this.logger.info(`${deviceLogMsg} -> done`); } catch(e: unknown) { - this.failedMessageCountPerDevice.set(device.getDeviceId, (this.failedMessageCountPerDevice.get(device.getDeviceId) ?? 0) + 1); + hadFailure = true; + logError(this.logger, `${deviceLogMsg} -> failed`, e); + } + } + + if (hadFailure) { + const failedMessageCount = (this.failedMessageCountPerDevice.get(device.getDeviceId) ?? 0) + 1; + this.failedMessageCountPerDevice.set(device.getDeviceId, failedMessageCount); - if ((this.failedMessageCountPerDevice.get(device.getDeviceId) ?? 0) > 10) { - this.logger.warn(`Device ${device.getDeviceId} has more than 10 failed update attempts, disconnecting it to prevent further issues`); - } + if (failedMessageCount % 10 === 0) { + this.logger.warn(`Device ${device.getDeviceId} has ${failedMessageCount} failed update attempts`); } + } else { + this.failedMessageCountPerDevice.delete(device.getDeviceId); } } } diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 9f5c88fb..e7ed905b 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -80,7 +80,13 @@ export default class AiroticDevice extends BleDevice { + const channels = value.split(','); + + if (channels.length !== 3) { + throw new Error(`Invalid color format: expected 3 components, got ${channels.length}`); + } + + const [r, g, b] = channels.map(c => { const channelNumber = parseInt(c, 10); if (isNaN(channelNumber) || channelNumber < 0 || channelNumber > 255) { @@ -88,7 +94,8 @@ export default class AiroticDevice extends BleDevice { if (!this.allPowerChannelValuesDefined(this.attributes)) { - return; + throw new Error('Cannot set channel power before all channel values have been initialized'); } const tmpData: { [K in keyof Zc95DevicePowerChannelAttributes]-?: InitializedIntRangeDeviceAttribute['value'] } = { diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 66789cf8..0eaf63b7 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -60,7 +60,6 @@ export default class BleObserver }; this.deviceManager.announceDetectedDevice(deviceInfo); - // this.announcedDevices.set(peripheral.id, peripheral); } private async observe(): Promise { diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index c87a9c98..2d190819 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -9,7 +9,7 @@ import {DeviceAttributeModifier} from "../../../../../src/device/attribute/devic import {Int} from "../../../../../src/util/numbers.js"; import {describe, it, expect} from "vitest"; import {mock} from "vitest-mock-extended"; -import { EventEmitter } from 'stream'; +import { EventEmitter } from 'events'; import { DeviceId } from '../../../../../src/device/deviceId.js'; describe('ButtplugIoDevice', () => { From e02046a1bfd3efa9bf6aeb1c6cb32a095d5bc745 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 1 Apr 2026 20:35:57 +0200 Subject: [PATCH 19/42] Fix race for scanning BLE --- src/device/transport/bleObserver.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 0eaf63b7..8f97292a 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -18,7 +18,7 @@ export default class BleObserver private readonly logger: Logger; - private readonly announcedDevices: Map = new Map(); + private isScanning = false; public constructor( deviceManager: DeviceManager, @@ -63,15 +63,21 @@ export default class BleObserver } private async observe(): Promise { + if (this.isScanning) { + return; + } + try { // Wait for Adapter poweredOn state await noble.waitForPoweredOnAsync(); + this.isScanning = true; await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true); this.logger.info('Looking for BLE UART devices'); } catch (error: unknown) { logError(this.logger, 'BLE device discovery error', error); + this.isScanning = false; await noble.stopScanningAsync(); } } From 50b680f40fc630e1dd48ba83689ca507e3e0bfda Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 1 Apr 2026 20:41:35 +0200 Subject: [PATCH 20/42] Rename DeviceId.fromPath() --- src/device/deviceId.ts | 2 +- src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts | 2 +- src/device/transport/bleObserver.ts | 2 +- src/device/transport/serialPortObserver.ts | 2 +- tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/device/deviceId.ts b/src/device/deviceId.ts index fc78042b..de4ff5a0 100644 --- a/src/device/deviceId.ts +++ b/src/device/deviceId.ts @@ -6,7 +6,7 @@ const deviceIdSymbol = Symbol(); export type DeviceId = string & { [deviceIdSymbol]: never } export const DeviceId = { - fromPath: (seed: string): DeviceId => { + create: (seed: string): DeviceId => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return uuidv5(seed, DEVICE_NAMESPACE).toString() as DeviceId; } diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index 0624282d..5b57be56 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -115,7 +115,7 @@ export default class ButtplugIoDeviceFactory // we need to use the index assigned to the device by Intiface. It's the best we have. // or the name if using Intiface-engine without id persistence const nameString = buttplugDevice.name.replace(/[^a-zA-Z0-9]/g, ''); - const deviceId = DeviceId.fromPath(useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); + const deviceId = DeviceId.create(useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); const knownDevice = this.settings.getKnownDeviceById(deviceId) diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 8f97292a..bbf7802d 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -55,7 +55,7 @@ export default class BleObserver } const deviceInfo: BleDeviceInfo = { - id: DeviceId.fromPath(peripheral.id), + id: DeviceId.create(peripheral.id), peripheral, }; diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index b2f58a04..5b65f942 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -62,7 +62,7 @@ export default class SerialPortObserver if (!this.managedDevices.has(portInfo.serialNumber)) { const deviceInfo: SerialDeviceInfo = { - id: DeviceId.fromPath(portInfo.serialNumber), + id: DeviceId.create(portInfo.serialNumber), portInfo }; diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index 2d190819..a08b8037 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -17,7 +17,7 @@ describe('ButtplugIoDevice', () => { function createDevice(buttplugDeviceMock: ButtplugClientDevice, attrs: ButtplugIoDeviceAttributes): ButtplugIoDevice { return new ButtplugIoDevice( - DeviceId.fromPath('device-id'), + DeviceId.create('device-id'), 'device name', 'device model', 'buttplugIo', From b230c8c739e92dec51a231276e22330aaff66f45 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 1 Apr 2026 20:55:47 +0200 Subject: [PATCH 21/42] Add Zc95Device test --- .../device/protocol/zc95/zc95Device.spec.ts | 773 ++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 tests/unit/device/protocol/zc95/zc95Device.spec.ts diff --git a/tests/unit/device/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts new file mode 100644 index 00000000..e0fa5b22 --- /dev/null +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -0,0 +1,773 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { mock, MockProxy } from 'vitest-mock-extended'; +import { EventEmitter } from 'events'; +import Zc95Device, { Zc95DeviceAttributes } from '../../../../../src/device/protocol/zc95/zc95Device.js'; +import Zc95Protocol, { MsgAndResponseIdentifier, MsgResponse } from '../../../../../src/device/protocol/zc95/zc95Protocol.js'; +import DeviceTransport from '../../../../../src/device/transport/deviceTransport.js'; +import MessageResponseHandler from '../../../../../src/device/protocol/messageResponseHandler.js'; +import Zc95MessageFactory, { + AckMsgResponse, + PatternDetailsMsgResponse, + PowerStatusMsgResponse, +} from '../../../../../src/device/protocol/zc95/zc95MessageFactory.js'; +import IntRangeDeviceAttribute, { + InitializedIntRangeDeviceAttribute, +} from '../../../../../src/device/attribute/intRangeDeviceAttribute.js'; +import ListDeviceAttribute, { + InitializedListDeviceAttribute, +} from '../../../../../src/device/attribute/listDeviceAttribute.js'; +import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; +import { Int } from '../../../../../src/util/numbers.js'; +import { DeviceId } from '../../../../../src/device/deviceId.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('Zc95Device', () => { + let mockProtocol: MockProxy; + let mockTransport: MockProxy; + let mockMsgHandler: MockProxy>; + let mockMsgFactory: MockProxy; + let mockLogger: MockProxy; + + const fakeMsgId = {} as MsgAndResponseIdentifier; + const okResponse: AckMsgResponse = { Type: 'Ack', MsgId: 1, Result: 'OK' }; + const errorResponse: AckMsgResponse = { Type: 'Ack', MsgId: 1, Result: 'ERROR', Error: 'something went wrong' }; + + function createActivePatternAttr( + currentValue: Int = Int.from(0) + ): InitializedListDeviceAttribute { + return ListDeviceAttribute.createInitialized( + 'activePattern', + 'Active Pattern', + DeviceAttributeModifier.readWrite, + [ + { key: Int.from(0), value: 'Pattern A' }, + { key: Int.from(1), value: 'Pattern B' }, + ], + currentValue, + ); + } + + function createPatternStartedAttr(initialValue: boolean = false) { + return BoolDeviceAttribute.createInitialized( + 'patternStarted', + 'Pattern Started', + DeviceAttributeModifier.readWrite, + initialValue, + ); + } + + function createPowerChannelAttrs(): Pick< + Zc95DeviceAttributes, + 'powerChannel1' | 'powerChannel2' | 'powerChannel3' | 'powerChannel4' + > { + const makeAttr = (ch: number) => + IntRangeDeviceAttribute.createInitialized( + `powerChannel${ch}`, + `Channel ${ch}`, + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + Int.from(10), + ); + + return { + powerChannel1: makeAttr(1), + powerChannel2: makeAttr(2), + powerChannel3: makeAttr(3), + powerChannel4: makeAttr(4), + }; + } + + function createDevice(attrs: Zc95DeviceAttributes): Zc95Device { + return new Zc95Device( + DeviceId.create('device-id'), + 'Test Device', + 'zc95', + new Date(), + '1.0.0', + mockProtocol, + mockTransport, + true, + attrs, + {}, + mockMsgFactory, + mockMsgHandler, + new EventEmitter(), + mockLogger, + ); + } + + function getOnReceiveCallback(): (data: Buffer) => void { + const calls = (mockTransport.onReceive as ReturnType['onReceive']).mock.calls; + return calls[0][0] as (data: Buffer) => void; + } + + beforeEach(() => { + mockProtocol = mock(); + mockTransport = mock(); + mockMsgHandler = mock>(); + mockMsgFactory = mock(); + mockLogger = mock(); + + mockTransport.getDeviceIdentifier.mockReturnValue('test-device'); + mockLogger.child.mockReturnValue(mockLogger); + }); + + describe('setAttribute', () => { + it('throws an error when setting a non-existing attribute', async () => { + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(), + }); + + await expect( + device.setAttribute('powerChannel1' as any, Int.from(5)) + ).rejects.toThrow("Attribute with name 'powerChannel1' does not exist for this device"); + }); + + it('throws an error when the value type is invalid for the attribute', async () => { + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(), + }); + + await expect( + device.setAttribute('patternStarted', 'not-a-bool' as any) + ).rejects.toThrow(); + }); + + describe('activePattern', () => { + it('does not send any messages when the pattern is already active', async () => { + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(false), + }); + + await device.setAttribute('activePattern', Int.from(0)); + + expect(mockMsgHandler.send).not.toHaveBeenCalled(); + }); + + it('switches to the new pattern when a different pattern is selected', async () => { + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(false), + }); + + await device.setAttribute('activePattern', Int.from(1)); + + const activePattern = await device.getAttribute('activePattern'); + expect(activePattern?.value).toStrictEqual(Int.from(1)); + expect(mockMsgHandler.send).not.toHaveBeenCalled(); + }); + + it('stops the running pattern before switching to the new pattern', async () => { + mockMsgFactory.createPatternStop.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(true), + ...createPowerChannelAttrs(), + }); + + await device.setAttribute('activePattern', Int.from(1)); + + expect(mockMsgFactory.createPatternStop).toHaveBeenCalledTimes(1); + const activePattern = await device.getAttribute('activePattern'); + expect(activePattern?.value).toStrictEqual(Int.from(1)); + }); + }); + + describe('patternStarted', () => { + it('does not send any messages when the pattern is already in the requested state', async () => { + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(false), + }); + + await device.setAttribute('patternStarted', false); + + expect(mockMsgHandler.send).not.toHaveBeenCalled(); + }); + + it('fetches pattern details and sends PatternStart when starting', async () => { + const patternDetailsResponse: PatternDetailsMsgResponse = { + Type: 'PatternDetail', + MsgId: 1, + Result: 'OK', + Name: 'Pattern A', + Id: 0, + ButtonA: '', + MenuItems: [], + }; + + mockMsgFactory.createGetPatternDetails.mockReturnValue(fakeMsgId); + mockMsgFactory.createPatternStart.mockReturnValue(fakeMsgId); + mockMsgHandler.send + .mockResolvedValueOnce(patternDetailsResponse) + .mockResolvedValueOnce(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(false), + }); + + await device.setAttribute('patternStarted', true); + + expect(mockMsgFactory.createGetPatternDetails).toHaveBeenCalledWith(Int.from(0)); + expect(mockMsgFactory.createPatternStart).toHaveBeenCalledWith(Int.from(0)); + expect(mockMsgHandler.send).toHaveBeenCalledTimes(2); + + const patternStarted = await device.getAttribute('patternStarted'); + expect(patternStarted?.value).toStrictEqual(true); + }); + + it('adds power channel attributes when starting the pattern', async () => { + const patternDetailsResponse: PatternDetailsMsgResponse = { + Type: 'PatternDetail', + MsgId: 1, + Result: 'OK', + Name: 'Pattern A', + Id: 0, + ButtonA: '', + MenuItems: [], + }; + + mockMsgFactory.createGetPatternDetails.mockReturnValue(fakeMsgId); + mockMsgFactory.createPatternStart.mockReturnValue(fakeMsgId); + mockMsgHandler.send + .mockResolvedValueOnce(patternDetailsResponse) + .mockResolvedValueOnce(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(false), + }); + + await device.setAttribute('patternStarted', true); + + expect(await device.getAttribute('powerChannel1')).toBeDefined(); + expect(await device.getAttribute('powerChannel2')).toBeDefined(); + expect(await device.getAttribute('powerChannel3')).toBeDefined(); + expect(await device.getAttribute('powerChannel4')).toBeDefined(); + }); + + it('creates MinMax pattern attributes from pattern details when starting', async () => { + const patternDetailsResponse: PatternDetailsMsgResponse = { + Type: 'PatternDetail', + MsgId: 1, + Result: 'OK', + Name: 'Pattern A', + Id: 0, + ButtonA: '', + MenuItems: [ + { + Id: 5, + Title: 'Intensity', + Group: 0, + Type: 'MIN_MAX', + Default: 50, + Min: 0, + Max: 100, + IncrementStep: 1, + UoM: '%', + }, + ], + }; + + mockMsgFactory.createGetPatternDetails.mockReturnValue(fakeMsgId); + mockMsgFactory.createPatternStart.mockReturnValue(fakeMsgId); + mockMsgHandler.send + .mockResolvedValueOnce(patternDetailsResponse) + .mockResolvedValueOnce(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(false), + }); + + await device.setAttribute('patternStarted', true); + + const patternAttr = await device.getAttribute('patternAttribute5' as any); + expect(patternAttr).toBeDefined(); + expect(patternAttr).toBeInstanceOf(IntRangeDeviceAttribute); + expect((patternAttr as InitializedIntRangeDeviceAttribute).value).toStrictEqual(Int.from(50)); + }); + + it('converts UoM "us" to "µs" when creating pattern attributes', async () => { + const patternDetailsResponse: PatternDetailsMsgResponse = { + Type: 'PatternDetail', + MsgId: 1, + Result: 'OK', + Name: 'Pattern A', + Id: 0, + ButtonA: '', + MenuItems: [ + { + Id: 3, + Title: 'Pulse Width', + Group: 0, + Type: 'MIN_MAX', + Default: 100, + Min: 0, + Max: 1000, + IncrementStep: 10, + UoM: 'us', + }, + ], + }; + + mockMsgFactory.createGetPatternDetails.mockReturnValue(fakeMsgId); + mockMsgFactory.createPatternStart.mockReturnValue(fakeMsgId); + mockMsgHandler.send + .mockResolvedValueOnce(patternDetailsResponse) + .mockResolvedValueOnce(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(false), + }); + + await device.setAttribute('patternStarted', true); + + const patternAttr = await device.getAttribute('patternAttribute3' as any); + expect(patternAttr?.uom).toStrictEqual('µs'); + }); + + it('creates MultiChoice pattern attributes from pattern details when starting', async () => { + const patternDetailsResponse: PatternDetailsMsgResponse = { + Type: 'PatternDetail', + MsgId: 1, + Result: 'OK', + Name: 'Pattern A', + Id: 0, + ButtonA: '', + MenuItems: [ + { + Id: 7, + Title: 'Mode', + Group: 0, + Type: 'MULTI_CHOICE', + Default: 0, + Choices: [ + { Id: 0, Name: 'Sine' }, + { Id: 1, Name: 'Square' }, + ], + }, + ], + }; + + mockMsgFactory.createGetPatternDetails.mockReturnValue(fakeMsgId); + mockMsgFactory.createPatternStart.mockReturnValue(fakeMsgId); + mockMsgHandler.send + .mockResolvedValueOnce(patternDetailsResponse) + .mockResolvedValueOnce(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(false), + }); + + await device.setAttribute('patternStarted', true); + + const patternAttr = await device.getAttribute('patternAttribute7' as any); + expect(patternAttr).toBeDefined(); + expect(patternAttr).toBeInstanceOf(ListDeviceAttribute); + expect((patternAttr as InitializedListDeviceAttribute).value).toStrictEqual(Int.from(0)); + }); + + it('sends PatternStop and removes power/pattern attributes when stopping', async () => { + mockMsgFactory.createPatternStop.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(true), + ...createPowerChannelAttrs(), + patternAttribute1: IntRangeDeviceAttribute.createInitialized( + 'patternAttribute1', + 'Intensity', + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + Int.from(50), + ), + }); + + await device.setAttribute('patternStarted', false); + + expect(mockMsgFactory.createPatternStop).toHaveBeenCalledTimes(1); + + const patternStarted = await device.getAttribute('patternStarted'); + expect(patternStarted?.value).toStrictEqual(false); + expect(await device.getAttribute('powerChannel1')).toBeUndefined(); + expect(await device.getAttribute('patternAttribute1' as any)).toBeUndefined(); + }); + + it('throws when the PatternStart response is not OK', async () => { + const patternDetailsResponse: PatternDetailsMsgResponse = { + Type: 'PatternDetail', + MsgId: 1, + Result: 'OK', + Name: 'Pattern A', + Id: 0, + ButtonA: '', + MenuItems: [], + }; + + mockMsgFactory.createGetPatternDetails.mockReturnValue(fakeMsgId); + mockMsgFactory.createPatternStart.mockReturnValue(fakeMsgId); + mockMsgHandler.send + .mockResolvedValueOnce(patternDetailsResponse) + .mockResolvedValueOnce(errorResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(false), + }); + + await expect(device.setAttribute('patternStarted', true)).rejects.toThrow( + 'Device response is not OK, but ERROR: something went wrong' + ); + }); + + it('throws when the PatternStop response is not OK', async () => { + mockMsgFactory.createPatternStop.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(errorResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(Int.from(0)), + patternStarted: createPatternStartedAttr(true), + ...createPowerChannelAttrs(), + }); + + await expect(device.setAttribute('patternStarted', false)).rejects.toThrow( + 'Device response is not OK, but ERROR: something went wrong' + ); + }); + }); + + describe('powerChannel', () => { + it('sends SetPower with all channel values multiplied by 10', async () => { + mockMsgFactory.createSetPower.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + ...createPowerChannelAttrs(), + }); + + await device.setAttribute('powerChannel1', Int.from(20)); + + expect(mockMsgFactory.createSetPower).toHaveBeenCalledWith(200, 100, 100, 100); + }); + + it('updates the attribute value after a successful SetPower', async () => { + mockMsgFactory.createSetPower.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + ...createPowerChannelAttrs(), + }); + + await device.setAttribute('powerChannel3', Int.from(42)); + + const attr = await device.getAttribute('powerChannel3'); + expect(attr?.value).toStrictEqual(Int.from(42)); + }); + + it('throws when the SetPower response is not OK', async () => { + mockMsgFactory.createSetPower.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(errorResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + ...createPowerChannelAttrs(), + }); + + await expect(device.setAttribute('powerChannel2', Int.from(5))).rejects.toThrow( + 'Device response is not OK, but ERROR: something went wrong' + ); + }); + + it('throws when not all power channel values are initialized', async () => { + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + powerChannel1: IntRangeDeviceAttribute.create( + 'powerChannel1', + 'Channel 1', + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + ), + powerChannel2: IntRangeDeviceAttribute.create( + 'powerChannel2', + 'Channel 2', + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + ), + powerChannel3: IntRangeDeviceAttribute.create( + 'powerChannel3', + 'Channel 3', + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + ), + powerChannel4: IntRangeDeviceAttribute.create( + 'powerChannel4', + 'Channel 4', + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + ), + }); + + await expect(device.setAttribute('powerChannel1', Int.from(5))).rejects.toThrow( + 'Cannot set channel power before all channel values have been initialized' + ); + }); + }); + + describe('patternAttribute (MinMax)', () => { + it('sends PatternMinMaxChange and updates the attribute value', async () => { + mockMsgFactory.createPatternMinMaxChange.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + patternAttribute5: IntRangeDeviceAttribute.createInitialized( + 'patternAttribute5', + 'Intensity', + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + Int.from(50), + ), + }); + + await device.setAttribute('patternAttribute5' as any, Int.from(75)); + + expect(mockMsgFactory.createPatternMinMaxChange).toHaveBeenCalledWith(5, Int.from(75)); + const attr = await device.getAttribute('patternAttribute5' as any); + expect(attr?.value).toStrictEqual(Int.from(75)); + }); + + it('throws when the PatternMinMaxChange response is not OK', async () => { + mockMsgFactory.createPatternMinMaxChange.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(errorResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + patternAttribute5: IntRangeDeviceAttribute.createInitialized( + 'patternAttribute5', + 'Intensity', + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + Int.from(50), + ), + }); + + await expect( + device.setAttribute('patternAttribute5' as any, Int.from(75)) + ).rejects.toThrow('Device response is not OK, but ERROR: something went wrong'); + }); + }); + + describe('patternAttribute (MultiChoice)', () => { + it('sends PatternMultiChoiceChange and updates the attribute value', async () => { + mockMsgFactory.createPatternMultiChoiceChange.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(okResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + patternAttribute7: ListDeviceAttribute.createInitialized( + 'patternAttribute7', + 'Mode', + DeviceAttributeModifier.readWrite, + [ + { key: Int.from(0), value: 'Sine' }, + { key: Int.from(1), value: 'Square' }, + ], + Int.from(0), + ), + }); + + await device.setAttribute('patternAttribute7' as any, Int.from(1)); + + expect(mockMsgFactory.createPatternMultiChoiceChange).toHaveBeenCalledWith(7, Int.from(1)); + const attr = await device.getAttribute('patternAttribute7' as any); + expect(attr?.value).toStrictEqual(Int.from(1)); + }); + + it('throws when the PatternMultiChoiceChange response is not OK', async () => { + mockMsgFactory.createPatternMultiChoiceChange.mockReturnValue(fakeMsgId); + mockMsgHandler.send.mockResolvedValue(errorResponse); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + patternAttribute7: ListDeviceAttribute.createInitialized( + 'patternAttribute7', + 'Mode', + DeviceAttributeModifier.readWrite, + [ + { key: Int.from(0), value: 'Sine' }, + { key: Int.from(1), value: 'Square' }, + ], + Int.from(0), + ), + }); + + await expect( + device.setAttribute('patternAttribute7' as any, Int.from(1)) + ).rejects.toThrow('Device response is not OK, but ERROR: something went wrong'); + }); + }); + }); + + describe('onReceivedMessage (unsolicited power status)', () => { + function buildPowerStatusBuffer(msg: PowerStatusMsgResponse): Buffer { + return Buffer.from(JSON.stringify(msg), 'utf-8'); + } + + const powerStatusMsg: PowerStatusMsgResponse = { + MsgId: -1, + Type: 'PowerStatus', + Result: 'OK', + Channels: [ + { Channel: 1, OutputPower: 200, MaxOutputPower: 500, PowerLimit: 700 }, + { Channel: 2, OutputPower: 100, MaxOutputPower: 300, PowerLimit: 1000 }, + { Channel: 3, OutputPower: 0, MaxOutputPower: 0, PowerLimit: 0 }, + { Channel: 4, OutputPower: 150, MaxOutputPower: 400, PowerLimit: 800 }, + ], + }; + + it('updates the channel max and value from power status', async () => { + mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + ...createPowerChannelAttrs(), + }); + + const onReceive = getOnReceiveCallback(); + onReceive(buildPowerStatusBuffer(powerStatusMsg)); + + // value = floor(MaxOutputPower * 0.1) = floor(500 * 0.1) = 50 + // max = floor(PowerLimit * 0.1) = floor(700 * 0.1) = 70 + const ch1 = await device.getAttribute('powerChannel1'); + expect(ch1?.value).toStrictEqual(Int.from(50)); + expect((ch1 as IntRangeDeviceAttribute)?.max).toStrictEqual(Int.from(70)); + + // value = floor(300 * 0.1) = 30, max = floor(1000 * 0.1) = 100 + const ch2 = await device.getAttribute('powerChannel2'); + expect(ch2?.value).toStrictEqual(Int.from(30)); + expect((ch2 as IntRangeDeviceAttribute)?.max).toStrictEqual(Int.from(100)); + }); + + it('sets value to MaxOutputPower percentage even when current value exceeded the power limit', async () => { + mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); + + const overLimitAttrs = createPowerChannelAttrs(); + overLimitAttrs.powerChannel1 = IntRangeDeviceAttribute.createInitialized( + 'powerChannel1', + 'Channel 1', + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + Int.from(90), // current value 90 was above the new power limit of 70 + ); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(true), + ...overLimitAttrs, + }); + + const onReceive = getOnReceiveCallback(); + onReceive(buildPowerStatusBuffer(powerStatusMsg)); + + // Final value = floor(MaxOutputPower * 0.1) = floor(500 * 0.1) = 50 + // max = floor(PowerLimit * 0.1) = floor(700 * 0.1) = 70 + const ch1 = await device.getAttribute('powerChannel1'); + expect(ch1?.value).toStrictEqual(Int.from(50)); + expect((ch1 as IntRangeDeviceAttribute)?.max).toStrictEqual(Int.from(70)); + }); + + it('ignores channels that are not in the attributes', () => { + mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); + + // No power channel attributes registered + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(false), + }); + + const onReceive = getOnReceiveCallback(); + expect(() => onReceive(buildPowerStatusBuffer(powerStatusMsg))).not.toThrow(); + }); + + it('logs an error and does not throw when message decoding fails', () => { + mockProtocol.decode.mockReturnValue({ + error: { type: 'invalid_frame', reason: 'bad JSON' }, + }); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(false), + }); + + const onReceive = getOnReceiveCallback(); + expect(() => onReceive(Buffer.from('not json'))).not.toThrow(); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('ignores messages that are not PowerStatus', () => { + const nonPowerStatusMsg: MsgResponse = { + MsgId: 42, + Type: 'Ack', + Result: 'OK', + }; + mockProtocol.decode.mockReturnValue({ message: nonPowerStatusMsg }); + + const device = createDevice({ + activePattern: createActivePatternAttr(), + patternStarted: createPatternStartedAttr(false), + }); + + const onReceive = getOnReceiveCallback(); + expect(() => onReceive(Buffer.from('{}'))).not.toThrow(); + }); + }); +}); From d92511d09671dfcd12eb8c49d163eeb91a63cf47 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 1 Apr 2026 21:20:13 +0200 Subject: [PATCH 22/42] Remove type assertions --- .../device/protocol/zc95/zc95Device.spec.ts | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/tests/unit/device/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts index e0fa5b22..e647e682 100644 --- a/tests/unit/device/protocol/zc95/zc95Device.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -10,9 +10,7 @@ import Zc95MessageFactory, { PatternDetailsMsgResponse, PowerStatusMsgResponse, } from '../../../../../src/device/protocol/zc95/zc95MessageFactory.js'; -import IntRangeDeviceAttribute, { - InitializedIntRangeDeviceAttribute, -} from '../../../../../src/device/attribute/intRangeDeviceAttribute.js'; +import IntRangeDeviceAttribute from '../../../../../src/device/attribute/intRangeDeviceAttribute.js'; import ListDeviceAttribute, { InitializedListDeviceAttribute, } from '../../../../../src/device/attribute/listDeviceAttribute.js'; @@ -101,8 +99,7 @@ describe('Zc95Device', () => { } function getOnReceiveCallback(): (data: Buffer) => void { - const calls = (mockTransport.onReceive as ReturnType['onReceive']).mock.calls; - return calls[0][0] as (data: Buffer) => void; + return mockTransport.onReceive.mock.calls[0][0]; } beforeEach(() => { @@ -124,7 +121,7 @@ describe('Zc95Device', () => { }); await expect( - device.setAttribute('powerChannel1' as any, Int.from(5)) + device.setAttribute('powerChannel1', Int.from(5)) ).rejects.toThrow("Attribute with name 'powerChannel1' does not exist for this device"); }); @@ -135,7 +132,7 @@ describe('Zc95Device', () => { }); await expect( - device.setAttribute('patternStarted', 'not-a-bool' as any) + device.setAttribute('patternStarted', 'not-a-bool') ).rejects.toThrow(); }); @@ -292,10 +289,10 @@ describe('Zc95Device', () => { await device.setAttribute('patternStarted', true); - const patternAttr = await device.getAttribute('patternAttribute5' as any); + const patternAttr = await device.getAttribute('patternAttribute5'); expect(patternAttr).toBeDefined(); expect(patternAttr).toBeInstanceOf(IntRangeDeviceAttribute); - expect((patternAttr as InitializedIntRangeDeviceAttribute).value).toStrictEqual(Int.from(50)); + expect(patternAttr?.value).toStrictEqual(Int.from(50)); }); it('converts UoM "us" to "µs" when creating pattern attributes', async () => { @@ -334,8 +331,10 @@ describe('Zc95Device', () => { await device.setAttribute('patternStarted', true); - const patternAttr = await device.getAttribute('patternAttribute3' as any); - expect(patternAttr?.uom).toStrictEqual('µs'); + const patternAttr = await device.getAttribute('patternAttribute3'); + expect(patternAttr).toBeInstanceOf(IntRangeDeviceAttribute); + if (!(patternAttr instanceof IntRangeDeviceAttribute)) return; + expect(patternAttr.uom).toStrictEqual('µs'); }); it('creates MultiChoice pattern attributes from pattern details when starting', async () => { @@ -374,10 +373,10 @@ describe('Zc95Device', () => { await device.setAttribute('patternStarted', true); - const patternAttr = await device.getAttribute('patternAttribute7' as any); + const patternAttr = await device.getAttribute('patternAttribute7'); expect(patternAttr).toBeDefined(); expect(patternAttr).toBeInstanceOf(ListDeviceAttribute); - expect((patternAttr as InitializedListDeviceAttribute).value).toStrictEqual(Int.from(0)); + expect(patternAttr?.value).toStrictEqual(Int.from(0)); }); it('sends PatternStop and removes power/pattern attributes when stopping', async () => { @@ -407,7 +406,7 @@ describe('Zc95Device', () => { const patternStarted = await device.getAttribute('patternStarted'); expect(patternStarted?.value).toStrictEqual(false); expect(await device.getAttribute('powerChannel1')).toBeUndefined(); - expect(await device.getAttribute('patternAttribute1' as any)).toBeUndefined(); + expect(await device.getAttribute('patternAttribute1')).toBeUndefined(); }); it('throws when the PatternStart response is not OK', async () => { @@ -568,10 +567,10 @@ describe('Zc95Device', () => { ), }); - await device.setAttribute('patternAttribute5' as any, Int.from(75)); + await device.setAttribute('patternAttribute5', Int.from(75)); expect(mockMsgFactory.createPatternMinMaxChange).toHaveBeenCalledWith(5, Int.from(75)); - const attr = await device.getAttribute('patternAttribute5' as any); + const attr = await device.getAttribute('patternAttribute5'); expect(attr?.value).toStrictEqual(Int.from(75)); }); @@ -595,7 +594,7 @@ describe('Zc95Device', () => { }); await expect( - device.setAttribute('patternAttribute5' as any, Int.from(75)) + device.setAttribute('patternAttribute5', Int.from(75)) ).rejects.toThrow('Device response is not OK, but ERROR: something went wrong'); }); }); @@ -620,10 +619,10 @@ describe('Zc95Device', () => { ), }); - await device.setAttribute('patternAttribute7' as any, Int.from(1)); + await device.setAttribute('patternAttribute7', Int.from(1)); expect(mockMsgFactory.createPatternMultiChoiceChange).toHaveBeenCalledWith(7, Int.from(1)); - const attr = await device.getAttribute('patternAttribute7' as any); + const attr = await device.getAttribute('patternAttribute7'); expect(attr?.value).toStrictEqual(Int.from(1)); }); @@ -647,7 +646,7 @@ describe('Zc95Device', () => { }); await expect( - device.setAttribute('patternAttribute7' as any, Int.from(1)) + device.setAttribute('patternAttribute7', Int.from(1)) ).rejects.toThrow('Device response is not OK, but ERROR: something went wrong'); }); }); @@ -686,12 +685,12 @@ describe('Zc95Device', () => { // max = floor(PowerLimit * 0.1) = floor(700 * 0.1) = 70 const ch1 = await device.getAttribute('powerChannel1'); expect(ch1?.value).toStrictEqual(Int.from(50)); - expect((ch1 as IntRangeDeviceAttribute)?.max).toStrictEqual(Int.from(70)); + expect(ch1?.max).toStrictEqual(Int.from(70)); // value = floor(300 * 0.1) = 30, max = floor(1000 * 0.1) = 100 const ch2 = await device.getAttribute('powerChannel2'); expect(ch2?.value).toStrictEqual(Int.from(30)); - expect((ch2 as IntRangeDeviceAttribute)?.max).toStrictEqual(Int.from(100)); + expect(ch2?.max).toStrictEqual(Int.from(100)); }); it('sets value to MaxOutputPower percentage even when current value exceeded the power limit', async () => { @@ -722,7 +721,7 @@ describe('Zc95Device', () => { // max = floor(PowerLimit * 0.1) = floor(700 * 0.1) = 70 const ch1 = await device.getAttribute('powerChannel1'); expect(ch1?.value).toStrictEqual(Int.from(50)); - expect((ch1 as IntRangeDeviceAttribute)?.max).toStrictEqual(Int.from(70)); + expect(ch1?.max).toStrictEqual(Int.from(70)); }); it('ignores channels that are not in the attributes', () => { From 5e7db27a16b013b2fde656f54c8184bbdace018b Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 1 Apr 2026 22:47:30 +0200 Subject: [PATCH 23/42] Add more device tests --- .../buttplugIo/buttplugIoDevice.spec.ts | 129 +++++- .../protocol/estim2b/estim2bDevice.spec.ts | 409 ++++++++++++++++++ .../genericSlvCtrlPlusDevice.spec.ts | 178 ++++++++ 3 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index a08b8037..6293c83d 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -1,6 +1,7 @@ -import {ButtplugClientDevice} from "buttplug"; +import {ButtplugClientDevice, MessageAttributes, SensorDeviceMessageAttributes, SensorType} from "buttplug"; import BoolDeviceAttribute from "../../../../../src/device/attribute/boolDeviceAttribute.js"; import IntRangeDeviceAttribute from "../../../../../src/device/attribute/intRangeDeviceAttribute.js"; +import IntDeviceAttribute from "../../../../../src/device/attribute/intDeviceAttribute.js"; import ButtplugIoDevice, { ButtplugIoDeviceAttributeKey, ButtplugIoDeviceAttributes @@ -102,4 +103,130 @@ describe('ButtplugIoDevice', () => { Scalar: newValue/rangeAttr.max }); }); + + it('it updates device data and calls buttplugClientDevice on setting boolean attribute to true', async () => { + + // Arrange + const buttplugDeviceMock = mock(); + const boolAttrKey: ButtplugIoDeviceAttributeKey = 'Rotate-1'; + const boolAttr = BoolDeviceAttribute.create(boolAttrKey, undefined, DeviceAttributeModifier.readWrite); + const device = createDevice(buttplugDeviceMock, {[boolAttrKey]: boolAttr}); + + // Act + await device.setAttribute(boolAttrKey, true); + + // Assert + expect((await device.getAttribute(boolAttrKey))?.value).toStrictEqual(true); + expect(buttplugDeviceMock.scalar).toHaveBeenCalledTimes(1); + expect(buttplugDeviceMock.scalar).toHaveBeenCalledWith({ActuatorType: 'Rotate', Index: 1, Scalar: 1}); + }); + + it('it throws an error if attribute is read-only', async () => { + + // Arrange + const buttplugDeviceMock = mock(); + const attrKey: ButtplugIoDeviceAttributeKey = 'Vibrate-1'; + const readOnlyAttr = BoolDeviceAttribute.create(attrKey, undefined, DeviceAttributeModifier.readOnly); + const device = createDevice(buttplugDeviceMock, {[attrKey]: readOnlyAttr}); + + // Act + const result = device.setAttribute(attrKey, false); + + // Assert + await expect(result).rejects.toThrow(`Attribute with name '${attrKey}' is readonly`); + expect(buttplugDeviceMock.scalar).not.toHaveBeenCalled(); + }); + + it('it throws an error for attribute with a sensor-type key', async () => { + + // Arrange + const buttplugDeviceMock = mock(); + const sensorAttrKey: ButtplugIoDeviceAttributeKey = 'Battery-1'; + const attr = BoolDeviceAttribute.create(sensorAttrKey, undefined, DeviceAttributeModifier.readWrite); + const device = createDevice(buttplugDeviceMock, {[sensorAttrKey]: attr}); + + // Act + const result = device.setAttribute(sensorAttrKey, false); + + // Assert + await expect(result).rejects.toThrow(`Attribute with name '${sensorAttrKey}' does not correspond to a valid actuator type key`); + expect(buttplugDeviceMock.scalar).not.toHaveBeenCalled(); + }); + + it('it throws when setting attribute with undefined value', async () => { + + // Arrange + const buttplugDeviceMock = mock(); + const attrKey: ButtplugIoDeviceAttributeKey = 'Vibrate-1'; + const attr = BoolDeviceAttribute.create(attrKey, undefined, DeviceAttributeModifier.readWrite); + const device = createDevice(buttplugDeviceMock, {[attrKey]: attr}); + + // Act + const result = device.setAttribute(attrKey, undefined); + + // Assert + await expect(result).rejects.toThrow(`Value to be set for attribute '${attrKey}' cannot be undefined`); + expect(buttplugDeviceMock.scalar).not.toHaveBeenCalled(); + }); + + it('it updates device data and calls buttplugClientDevice on setting int attribute', async () => { + + // Arrange + const buttplugDeviceMock = mock(); + const attrKey: ButtplugIoDeviceAttributeKey = 'Oscillate-1'; + const attr = IntDeviceAttribute.createInitialized(attrKey, undefined, DeviceAttributeModifier.readWrite, undefined, Int.from(5)); + const device = createDevice(buttplugDeviceMock, {[attrKey]: attr}); + + const newValue = Int.from(10); + + // Act + await device.setAttribute(attrKey, newValue); + + // Assert + expect((await device.getAttribute(attrKey))?.value).toStrictEqual(newValue); + expect(buttplugDeviceMock.scalar).toHaveBeenCalledTimes(1); + expect(buttplugDeviceMock.scalar).toHaveBeenCalledWith({ ActuatorType: 'Oscillate', Index: 1, Scalar: newValue }); + }); + + it('it updates sensor attribute values on refresh', async () => { + + // Arrange + const buttplugDeviceMock = mock(); + const sensorAttrKey: ButtplugIoDeviceAttributeKey = 'Battery-0'; + const sensorAttr = IntDeviceAttribute.createInitialized(sensorAttrKey, undefined, DeviceAttributeModifier.readOnly, undefined, Int.from(0)); + const device = createDevice(buttplugDeviceMock, {[sensorAttrKey]: sensorAttr}); + + const sensorAttrDef = new SensorDeviceMessageAttributes({ Index: 0 }); + sensorAttrDef.SensorType = SensorType.Battery; + Object.defineProperty(buttplugDeviceMock, 'messageAttributes', { + get: () => new MessageAttributes({ SensorReadCmd: [sensorAttrDef] }), + configurable: true, + }); + buttplugDeviceMock.sensorRead.mockResolvedValue([85]); + + // Act + await device.refresh(); + + // Assert + expect(buttplugDeviceMock.sensorRead).toHaveBeenCalledWith(0, SensorType.Battery); + expect((await device.getAttribute(sensorAttrKey))?.value).toStrictEqual(Int.from(85)); + }); + + it('it skips sensor refresh when device has no SensorReadCmd', async () => { + + // Arrange + const buttplugDeviceMock = mock(); + const device = createDevice(buttplugDeviceMock, {}); + + Object.defineProperty(buttplugDeviceMock, 'messageAttributes', { + get: () => new MessageAttributes({}), + configurable: true, + }); + + // Act + await device.refresh(); + + // Assert + expect(buttplugDeviceMock.sensorRead).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts new file mode 100644 index 00000000..e6c30d05 --- /dev/null +++ b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import EStim2bDevice, { EStim2bDeviceAttributes } from '../../../../../src/device/protocol/estim2b/estim2bDevice.js'; +import EStim2bProtocol, { EStim2bMode, EStim2bStatus } from '../../../../../src/device/protocol/estim2b/estim2bProtocol.js'; +import DeviceTransport from '../../../../../src/device/transport/deviceTransport.js'; +import IntRangeDeviceAttribute from '../../../../../src/device/attribute/intRangeDeviceAttribute.js'; +import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceAttribute.js'; +import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAttribute.js'; +import ListDeviceAttribute from '../../../../../src/device/attribute/listDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; +import { Int } from '../../../../../src/util/numbers.js'; +import { DeviceId } from '../../../../../src/device/deviceId.js'; +import Logger from '../../../../../src/logging/Logger.js'; +import EventEmitter from 'events'; + +describe('EStim2bDevice', () => { + + function createStatus(overrides: Partial = {}): EStim2bStatus { + return { + batteryLevel: 600, + channelALevel: 20, + channelBLevel: 10, + pulseFrequency: 50, + pulsePwm: 50, + currentMode: EStim2bMode.pulse, + powerMode: 'L', + channelsJoined: false, + firmwareVersion: '1.0.0', + ...overrides, + }; + } + + function createAttributes(): EStim2bDeviceAttributes { + const modeOptions = [ + { key: Int.from(EStim2bMode.pulse), value: 'Pulse' }, + { key: Int.from(EStim2bMode.bounce), value: 'Bounce' }, + { key: Int.from(EStim2bMode.continuous), value: 'Continuous' }, + { key: Int.from(EStim2bMode.aSplit), value: 'A-Split' }, + { key: Int.from(EStim2bMode.bSplit), value: 'B-Split' }, + { key: Int.from(EStim2bMode.wave), value: 'Wave' }, + { key: Int.from(EStim2bMode.waterfall), value: 'Waterfall' }, + { key: Int.from(EStim2bMode.squeeze), value: 'Squeeze' }, + { key: Int.from(EStim2bMode.milk), value: 'Milk' }, + { key: Int.from(EStim2bMode.throb), value: 'Throb' }, + { key: Int.from(EStim2bMode.thrust), value: 'Thrust' }, + { key: Int.from(EStim2bMode.random), value: 'Random' }, + { key: Int.from(EStim2bMode.step), value: 'Step' }, + { key: Int.from(EStim2bMode.training), value: 'Training' }, + ]; + + return { + mode: ListDeviceAttribute.create('mode', 'Mode', DeviceAttributeModifier.readWrite, modeOptions), + channelALevel: IntRangeDeviceAttribute.create('channelALevel', 'Channel A', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1)), + channelBLevel: IntRangeDeviceAttribute.create('channelBLevel', 'Channel B', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1)), + channelsJoined: BoolDeviceAttribute.create('channelsJoined', 'Channels Joined', DeviceAttributeModifier.readOnly), + highPowerMode: BoolDeviceAttribute.create('highPowerMode', 'High Power Mode', DeviceAttributeModifier.readWrite), + batteryStatus: StrDeviceAttribute.create('batteryStatus', 'Battery', DeviceAttributeModifier.readOnly), + }; + } + + function createDevice( + status: EStim2bStatus, + protocol: EStim2bProtocol, + transport: DeviceTransport, + ): EStim2bDevice { + return new EStim2bDevice( + DeviceId.create('device-id'), + 'ET-312', + 'estim2b', + new Date(), + true, + status, + protocol, + transport, + createAttributes(), + new EventEmitter(), + mock(), + ); + } + + describe('humanReadableBatteryLevel', () => { + + it.each([ + { adc: 721, expected: 'mains' }, + { adc: 600, expected: 'full' }, + { adc: 535, expected: 'medium' }, + { adc: 510, expected: 'low' }, + { adc: 500, expected: 'critical' }, + ])('returns $expected for ADC value $adc', ({ adc, expected }) => { + expect(EStim2bDevice.humanReadableBatteryLevel(adc)).toStrictEqual(expected); + }); + }); + + it('it sets mode and updates attributes', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); + const responseStatus = createStatus({ currentMode: EStim2bMode.bounce }); + const encodedCommand = Buffer.from('M1'); + const responseBuffer = Buffer.from('response'); + + mockProtocol.createSetModeCommand.mockReturnValue(`M${EStim2bMode.bounce}`); + mockProtocol.encode.mockReturnValue(encodedCommand); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ message: responseStatus }); + + const device = createDevice(initialStatus, mockProtocol, mockTransport); + + // Act + await device.setAttribute('mode', Int.from(EStim2bMode.bounce)); + + // Assert + expect(mockProtocol.createSetModeCommand).toHaveBeenCalledWith(EStim2bMode.bounce); + expect(mockProtocol.encode).toHaveBeenCalledWith(`M${EStim2bMode.bounce}`); + expect(mockTransport.sendAndAwaitReceive).toHaveBeenCalledWith(encodedCommand, 250); + expect(mockProtocol.decode).toHaveBeenCalledWith(responseBuffer); + expect((await device.getAttribute('mode'))?.value).toStrictEqual(Int.from(EStim2bMode.bounce)); + }); + + it('it sets channel A level and updates attributes', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const initialStatus = createStatus(); + const responseStatus = createStatus({ channelALevel: 50 }); + const encodedCommand = Buffer.from('A50'); + const responseBuffer = Buffer.from('response'); + + mockProtocol.createSetPowerCommand.mockReturnValue('A50'); + mockProtocol.encode.mockReturnValue(encodedCommand); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ message: responseStatus }); + + const device = createDevice(initialStatus, mockProtocol, mockTransport); + + // Act + await device.setAttribute('channelALevel', Int.from(50)); + + // Assert + expect(mockProtocol.createSetPowerCommand).toHaveBeenCalledWith('A', 50); + expect(mockTransport.sendAndAwaitReceive).toHaveBeenCalledWith(encodedCommand, 250); + expect((await device.getAttribute('channelALevel'))?.value).toStrictEqual(Int.from(50)); + }); + + it('it sets channel B level and updates attributes', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const initialStatus = createStatus(); + const responseStatus = createStatus({ channelBLevel: 30 }); + const encodedCommand = Buffer.from('B30'); + const responseBuffer = Buffer.from('response'); + + mockProtocol.createSetPowerCommand.mockReturnValue('B30'); + mockProtocol.encode.mockReturnValue(encodedCommand); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ message: responseStatus }); + + const device = createDevice(initialStatus, mockProtocol, mockTransport); + + // Act + await device.setAttribute('channelBLevel', Int.from(30)); + + // Assert + expect(mockProtocol.createSetPowerCommand).toHaveBeenCalledWith('B', 30); + expect(mockTransport.sendAndAwaitReceive).toHaveBeenCalledWith(encodedCommand, 250); + expect((await device.getAttribute('channelBLevel'))?.value).toStrictEqual(Int.from(30)); + }); + + it('it sets pulse frequency and updates attributes', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); + const responseStatus = createStatus({ currentMode: EStim2bMode.pulse, pulseFrequency: 75 }); + const encodedCommand = Buffer.from('C75'); + const responseBuffer = Buffer.from('response'); + + mockProtocol.createSetPulseFrequencyCommand.mockReturnValue('C75'); + mockProtocol.encode.mockReturnValue(encodedCommand); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ message: responseStatus }); + + const device = createDevice(initialStatus, mockProtocol, mockTransport); + + // Act + await device.setAttribute('pulseFrequency', Int.from(75)); + + // Assert + expect(mockProtocol.createSetPulseFrequencyCommand).toHaveBeenCalledWith(75); + expect(mockTransport.sendAndAwaitReceive).toHaveBeenCalledWith(encodedCommand, 250); + expect((await device.getAttribute('pulseFrequency'))?.value).toStrictEqual(Int.from(75)); + }); + + it('it sets pulse PWM and updates attributes', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); + const responseStatus = createStatus({ currentMode: EStim2bMode.pulse, pulsePwm: 25 }); + const encodedCommand = Buffer.from('D25'); + const responseBuffer = Buffer.from('response'); + + mockProtocol.createSetPulsePwmCommand.mockReturnValue('D25'); + mockProtocol.encode.mockReturnValue(encodedCommand); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ message: responseStatus }); + + const device = createDevice(initialStatus, mockProtocol, mockTransport); + + // Act + await device.setAttribute('pulsePwm', Int.from(25)); + + // Assert + expect(mockProtocol.createSetPulsePwmCommand).toHaveBeenCalledWith(25); + expect(mockTransport.sendAndAwaitReceive).toHaveBeenCalledWith(encodedCommand, 250); + expect((await device.getAttribute('pulsePwm'))?.value).toStrictEqual(Int.from(25)); + }); + + it('it sets high power mode to high', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const initialStatus = createStatus({ powerMode: 'L' }); + const responseStatus = createStatus({ powerMode: 'H' }); + const encodedCommand = Buffer.from('H'); + const responseBuffer = Buffer.from('response'); + + mockProtocol.createSetPowerModeCommand.mockReturnValue('H'); + mockProtocol.encode.mockReturnValue(encodedCommand); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ message: responseStatus }); + + const device = createDevice(initialStatus, mockProtocol, mockTransport); + + // Act + await device.setAttribute('highPowerMode', true); + + // Assert + expect(mockProtocol.createSetPowerModeCommand).toHaveBeenCalledWith('H'); + expect(mockTransport.sendAndAwaitReceive).toHaveBeenCalledWith(encodedCommand, 250); + expect((await device.getAttribute('highPowerMode'))?.value).toStrictEqual(true); + }); + + it('it sets high power mode to low', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const initialStatus = createStatus({ powerMode: 'H' }); + const responseStatus = createStatus({ powerMode: 'L' }); + const encodedCommand = Buffer.from('L'); + const responseBuffer = Buffer.from('response'); + + mockProtocol.createSetPowerModeCommand.mockReturnValue('L'); + mockProtocol.encode.mockReturnValue(encodedCommand); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ message: responseStatus }); + + const device = createDevice(initialStatus, mockProtocol, mockTransport); + + // Act + await device.setAttribute('highPowerMode', false); + + // Assert + expect(mockProtocol.createSetPowerModeCommand).toHaveBeenCalledWith('L'); + expect(mockTransport.sendAndAwaitReceive).toHaveBeenCalledWith(encodedCommand, 250); + expect((await device.getAttribute('highPowerMode'))?.value).toStrictEqual(false); + }); + + it('it throws when trying to set channelsJoined', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const device = createDevice(createStatus(), mockProtocol, mockTransport); + + // Act + const result = device.setAttribute('channelsJoined', false); + + // Assert + await expect(result).rejects.toThrow( + `Could not set value false (type: boolean) for attribute 'channelsJoined'` + ); + expect(mockTransport.sendAndAwaitReceive).not.toHaveBeenCalled(); + }); + + it('it throws when trying to set batteryStatus', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const device = createDevice(createStatus(), mockProtocol, mockTransport); + + // Act + const result = device.setAttribute('batteryStatus', 'mains'); + + // Assert + await expect(result).rejects.toThrow( + `Could not set value "mains" (type: string) for attribute 'batteryStatus'` + ); + expect(mockTransport.sendAndAwaitReceive).not.toHaveBeenCalled(); + }); + + it('it throws when attribute does not exist on the device', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + // Continuous mode only adds pulseFrequency — pulsePwm is absent + const device = createDevice(createStatus({ currentMode: EStim2bMode.continuous }), mockProtocol, mockTransport); + + // Act + const result = device.setAttribute('pulsePwm', Int.from(50)); + + // Assert + await expect(result).rejects.toThrow(`Attribute 'pulsePwm' does not exist`); + expect(mockTransport.sendAndAwaitReceive).not.toHaveBeenCalled(); + }); + + it('it propagates transport errors', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const errorMessage = 'Connection lost'; + + mockProtocol.createSetPowerCommand.mockReturnValue('A50'); + mockProtocol.encode.mockReturnValue(Buffer.from('A50')); + mockTransport.sendAndAwaitReceive.mockRejectedValue(new Error(errorMessage)); + + const device = createDevice(createStatus(), mockProtocol, mockTransport); + + // Act + const result = device.setAttribute('channelALevel', Int.from(50)); + + // Assert + await expect(result).rejects.toThrow(errorMessage); + expect(mockProtocol.decode).not.toHaveBeenCalled(); + }); + + it('it throws when protocol decode returns an error', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const responseBuffer = Buffer.from('bad response'); + + mockProtocol.createSetPowerCommand.mockReturnValue('A50'); + mockProtocol.encode.mockReturnValue(Buffer.from('A50')); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ error: { type: 'invalid_frame', reason: 'unexpected byte' } }); + + const device = createDevice(createStatus(), mockProtocol, mockTransport); + + // Act + const result = device.setAttribute('channelALevel', Int.from(50)); + + // Assert + await expect(result).rejects.toThrow("Invalid frame for response 'bad response': unexpected byte"); + }); + + it('it updates attribute values on refresh', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const initialStatus = createStatus(); + const refreshedStatus = createStatus({ + channelALevel: 75, + channelBLevel: 40, + pulseFrequency: 80, + pulsePwm: 30, + powerMode: 'H', + channelsJoined: true, + batteryLevel: 510, + }); + const encodedCommand = Buffer.from(''); + const responseBuffer = Buffer.from('response'); + + mockProtocol.createGetStatusCommand.mockReturnValue(''); + mockProtocol.encode.mockReturnValue(encodedCommand); + mockTransport.sendAndAwaitReceive.mockResolvedValue(responseBuffer); + mockProtocol.decode.mockReturnValue({ message: refreshedStatus }); + + const device = createDevice(initialStatus, mockProtocol, mockTransport); + + // Act + await device.refresh(); + + // Assert + expect(mockProtocol.createGetStatusCommand).toHaveBeenCalled(); + expect(mockTransport.sendAndAwaitReceive).toHaveBeenCalledWith(encodedCommand, 250); + expect((await device.getAttribute('channelALevel'))?.value).toStrictEqual(Int.from(75)); + expect((await device.getAttribute('channelBLevel'))?.value).toStrictEqual(Int.from(40)); + expect((await device.getAttribute('pulseFrequency'))?.value).toStrictEqual(Int.from(80)); + expect((await device.getAttribute('pulsePwm'))?.value).toStrictEqual(Int.from(30)); + expect((await device.getAttribute('highPowerMode'))?.value).toStrictEqual(true); + expect((await device.getAttribute('channelsJoined'))?.value).toStrictEqual(true); + expect((await device.getAttribute('batteryStatus'))?.value).toStrictEqual('low'); + }); +}); diff --git a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts index 41948f32..d6da15a6 100644 --- a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts +++ b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts @@ -8,6 +8,9 @@ import SlvCtrlProtocol from '../../../../../src/device/protocol/slvCtrlPlus/slvC import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAttribute.js'; import DeviceTransport from '../../../../../src/device/transport/deviceTransport.js'; import { matchStrictlyEqual } from '../../../helper/matchers.js'; +import Logger from '../../../../../src/logging/Logger.js'; +import EventEmitter from 'events'; +import { DeviceId } from '../../../../../src/device/deviceId.js'; describe('GenericSlvCtrlPlusDevice', () => { @@ -158,4 +161,179 @@ describe('GenericSlvCtrlPlusDevice', () => { expect(mockProtocol.decode).not.toHaveBeenCalled(); await expect(result).rejects.toThrow(`A non-null value must be set for the attribute with name '${attrName}'`); }); + + it('it fails to set attribute: value is invalid for attribute type', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + + const attrName = 'bool'; + const device = createDevice( + { [attrName]: new BoolDeviceAttribute(attrName, 'Bool', DeviceAttributeModifier.readWrite, undefined) }, + mockProtocol, + mockTransport, + ); + + // Act + const result = device.setAttribute(attrName, 'notABool'); + + // Assert + expect(mockProtocol.encode).not.toHaveBeenCalled(); + expect(mockTransport.sendAndAwaitReceive).not.toHaveBeenCalled(); + await expect(result).rejects.toThrow(`Value for attribute with name '${attrName}' is not valid.`); + }); + + it('it updates attribute values on refresh', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const mockLogger = mock(); + + const device = new GenericSlvCtrlPlusDevice( + 10000, + DeviceId.create('device-id'), + 'Device', + 'model', + 'provider', + new Date(), + mockProtocol, + mockTransport, + 10000, + { + bool: new BoolDeviceAttribute('bool', 'Bool', DeviceAttributeModifier.readWrite, undefined), + str: new StrDeviceAttribute('str', 'Str', DeviceAttributeModifier.readWrite, undefined), + }, + new EventEmitter(), + mockLogger, + ); + + const statusCommand = { command: 'status', args: [] }; + const rawStatusCommand = 'status'; + const rawStatusResponse = 'status;;status:ok'; + + mockProtocol.encode + .calledWith(matchStrictlyEqual(statusCommand)) + .mockReturnValue(Buffer.from(rawStatusCommand)); + mockTransport.sendAndAwaitReceive + .calledWith(matchStrictlyEqual(Buffer.from(rawStatusCommand))) + .mockResolvedValue(Buffer.from(rawStatusResponse)); + mockProtocol.decode + .calledWith(matchStrictlyEqual(Buffer.from(rawStatusResponse))) + .mockReturnValue({ + message: { + command: rawStatusCommand, + data: { bool: '1', str: 'hello' }, + result: { status: 'ok' }, + } + }); + + // Act + await device.refresh(); + + // Assert + expect((await device.getAttribute('bool'))?.value).toStrictEqual(true); + expect((await device.getAttribute('str'))?.value).toStrictEqual('hello'); + }); + + it('it sets attribute value to undefined when response data value is empty string', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const mockLogger = mock(); + + const device = new GenericSlvCtrlPlusDevice( + 10000, + DeviceId.create('device-id'), + 'Device', + 'model', + 'provider', + new Date(), + mockProtocol, + mockTransport, + 10000, + { + bool: new BoolDeviceAttribute('bool', 'Bool', DeviceAttributeModifier.readWrite, true), + }, + new EventEmitter(), + mockLogger, + ); + + const statusCommand = { command: 'status', args: [] }; + const rawStatusCommand = 'status'; + const rawStatusResponse = 'status;;status:ok'; + + mockProtocol.encode + .calledWith(matchStrictlyEqual(statusCommand)) + .mockReturnValue(Buffer.from(rawStatusCommand)); + mockTransport.sendAndAwaitReceive + .calledWith(matchStrictlyEqual(Buffer.from(rawStatusCommand))) + .mockResolvedValue(Buffer.from(rawStatusResponse)); + mockProtocol.decode + .calledWith(matchStrictlyEqual(Buffer.from(rawStatusResponse))) + .mockReturnValue({ + message: { + command: rawStatusCommand, + data: { bool: '' }, + result: { status: 'ok' }, + } + }); + + // Act + await device.refresh(); + + // Assert + expect((await device.getAttribute('bool'))?.value).toBeUndefined(); + }); + + it('it ignores unknown attributes in refresh response', async () => { + + // Arrange + const mockProtocol = mock(); + const mockTransport = mock(); + const mockLogger = mock(); + + const device = new GenericSlvCtrlPlusDevice( + 10000, + DeviceId.create('device-id'), + 'Device', + 'model', + 'provider', + new Date(), + mockProtocol, + mockTransport, + 10000, + { + bool: new BoolDeviceAttribute('bool', 'Bool', DeviceAttributeModifier.readWrite, undefined), + }, + new EventEmitter(), + mockLogger, + ); + + const statusCommand = { command: 'status', args: [] }; + const rawStatusCommand = 'status'; + const rawStatusResponse = 'status;;status:ok'; + + mockProtocol.encode + .calledWith(matchStrictlyEqual(statusCommand)) + .mockReturnValue(Buffer.from(rawStatusCommand)); + mockTransport.sendAndAwaitReceive + .calledWith(matchStrictlyEqual(Buffer.from(rawStatusCommand))) + .mockResolvedValue(Buffer.from(rawStatusResponse)); + mockProtocol.decode + .calledWith(matchStrictlyEqual(Buffer.from(rawStatusResponse))) + .mockReturnValue({ + message: { + command: rawStatusCommand, + data: { bool: '1', unknownAttr: 'something' }, + result: { status: 'ok' }, + } + }); + + // Act & Assert - should not throw when encountering unknown attribute in response + await expect(device.refresh()).resolves.not.toThrow(); + expect((await device.getAttribute('bool'))?.value).toStrictEqual(true); + }); }) \ No newline at end of file From a798b24b0e1f4c5dffb587a2e5c238701f6ce8e9 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 1 Apr 2026 22:52:03 +0200 Subject: [PATCH 24/42] Fix errors in test --- .../protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts index d6da15a6..f4526517 100644 --- a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts +++ b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts @@ -20,14 +20,15 @@ describe('GenericSlvCtrlPlusDevice', () => { transport: DeviceTransport, ): GenericSlvCtrlPlusDevice { const fwVersion = 10000; - const deviceUuid = 'foo-bar-baz'; + const deviceUuid = DeviceId.create('foo-bar-baz'); const deviceName = 'Aston Martin'; const model = 'et312'; const protocolVersion = 10000; const provider = 'dummy'; return new GenericSlvCtrlPlusDevice( - fwVersion, deviceUuid, deviceName, model, provider, new Date(), protocol, transport, protocolVersion, attrs + fwVersion, deviceUuid, deviceName, model, provider, new Date(), protocol, transport, protocolVersion, attrs, + mock(), mock(), ); } From 26cddb9578686bb7b4a20ba2a7ea99ba7f28405a Mon Sep 17 00:00:00 2001 From: HRS Date: Thu, 2 Apr 2026 07:57:14 +0200 Subject: [PATCH 25/42] Some more tests --- tests/unit/device/bleDevice.spec.ts | 201 ++++++++++++++++ tests/unit/device/deviceManager.spec.ts | 207 +++++++++++++++- .../protocol/airotic/airoticDevice.spec.ts | 222 ++++++++++++++++++ .../protocol/airotic/airoticProtocol.spec.ts | 112 +++++++++ .../protocol/estim2b/estim2bProtocol.spec.ts | 204 ++++++++++++++++ .../slvCtrlPlus/slvCtrlProtocolLegacy.spec.ts | 74 +++++- .../slvCtrlPlus/slvCtrlProtocolV1.spec.ts | 50 ++++ .../device/protocol/zc95/zc95Protocol.spec.ts | 72 ++++++ 8 files changed, 1138 insertions(+), 4 deletions(-) create mode 100644 tests/unit/device/bleDevice.spec.ts create mode 100644 tests/unit/device/protocol/airotic/airoticDevice.spec.ts create mode 100644 tests/unit/device/protocol/airotic/airoticProtocol.spec.ts create mode 100644 tests/unit/device/protocol/estim2b/estim2bProtocol.spec.ts create mode 100644 tests/unit/device/protocol/zc95/zc95Protocol.spec.ts diff --git a/tests/unit/device/bleDevice.spec.ts b/tests/unit/device/bleDevice.spec.ts new file mode 100644 index 00000000..ad9de4c6 --- /dev/null +++ b/tests/unit/device/bleDevice.spec.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { Peripheral, PeripheralState } from '@stoprocent/noble'; +import EventEmitter from 'events'; +import { DeviceId } from '../../../src/device/deviceId.js'; +import BleDevice from '../../../src/device/bleDevice.js'; +import { AttributeKeyOf, AttributeValueOf, DeviceAttributes } from '../../../src/device/device.js'; +import { NoDeviceConfig } from '../../../src/device/deviceConfig.js'; +import Logger from '../../../src/logging/Logger.js'; +import DeviceState from '../../../src/device/deviceState.js'; +import { DeviceEvent } from '../../../src/device/device.js'; + +class TestBleDevice extends BleDevice { + public constructor( + deviceId: DeviceId, + deviceName: string, + provider: string, + peripheral: Peripheral, + connectedSince: Date, + controllable: boolean, + attributes: DeviceAttributes, + config: NoDeviceConfig, + eventEmitter: EventEmitter, + logger: Logger, + ) { + super(deviceId, deviceName, provider, peripheral, connectedSince, controllable, attributes, config, eventEmitter, logger); + } + + public async setAttribute>( + _attributeName: K, + _value: AttributeValueOf, + ): Promise> { + throw new Error('Not implemented'); + } +} + +describe('BleDevice', () => { + let mockPeripheral: ReturnType>; + let mockLogger: ReturnType>; + let peripheralState: PeripheralState; + + function createDevice(eventEmitter = new EventEmitter()): TestBleDevice { + return new TestBleDevice( + DeviceId.create('test-device'), + 'Test Device', + 'test-provider', + mockPeripheral, + new Date(), + true, + {}, + {}, + eventEmitter, + mockLogger, + ); + } + + beforeEach(() => { + vi.useFakeTimers(); + + mockPeripheral = mock(); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + peripheralState = 'connected'; + Object.defineProperty(mockPeripheral, 'rssi', { get: () => -50, configurable: true }); + Object.defineProperty(mockPeripheral, 'state', { get: () => peripheralState, configurable: true }); + + mockPeripheral.disconnectAsync.mockResolvedValue(undefined); + mockPeripheral.updateRssiAsync.mockResolvedValue(-60); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('subscribes to disconnect event on peripheral', () => { + createDevice(); + + expect(mockPeripheral.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); + }); + }); + + describe('RSSI update interval', () => { + it('calls updateRssiAsync after interval fires', async () => { + createDevice(); + + expect(mockPeripheral.updateRssiAsync).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(5000); + + expect(mockPeripheral.updateRssiAsync).toHaveBeenCalled(); + }); + + it('skips update when peripheral is disconnected', async () => { + peripheralState = 'disconnected'; + createDevice(); + + await vi.advanceTimersByTimeAsync(5000); + + expect(mockPeripheral.updateRssiAsync).not.toHaveBeenCalled(); + }); + + it('handles updateRssiAsync errors gracefully without throwing', async () => { + mockPeripheral.updateRssiAsync.mockRejectedValue(new Error('BLE stack error')); + createDevice(); + + await expect(vi.advanceTimersByTimeAsync(5000)).resolves.not.toThrow(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); + + describe('close', () => { + it('disconnects peripheral when state is connected', async () => { + const device = createDevice(); + + await device.close(); + + expect(mockPeripheral.disconnectAsync).toHaveBeenCalled(); + expect(mockPeripheral.cancelConnect).not.toHaveBeenCalled(); + }); + + it('cancels connect when state is connecting', async () => { + peripheralState = 'connecting'; + const device = createDevice(); + + await device.close(); + + expect(mockPeripheral.cancelConnect).toHaveBeenCalled(); + expect(mockPeripheral.disconnectAsync).not.toHaveBeenCalled(); + }); + + it('does not disconnect when state is disconnected', async () => { + peripheralState = 'disconnected'; + const device = createDevice(); + + await device.close(); + + expect(mockPeripheral.disconnectAsync).not.toHaveBeenCalled(); + expect(mockPeripheral.cancelConnect).not.toHaveBeenCalled(); + }); + + it('clears RSSI interval so no more updates are sent', async () => { + const device = createDevice(); + + await device.close(); + await vi.advanceTimersByTimeAsync(15000); + + expect(mockPeripheral.updateRssiAsync).not.toHaveBeenCalled(); + }); + + it('removes disconnect listener from peripheral', async () => { + const device = createDevice(); + + await device.close(); + + expect(mockPeripheral.off).toHaveBeenCalledWith('disconnect', expect.any(Function)); + }); + + it('sets device state to closed', async () => { + const device = createDevice(); + + await device.close(); + + expect(device.getState).toStrictEqual(DeviceState.closed); + }); + + it('emits deviceDisconnected event', async () => { + const eventEmitter = new EventEmitter(); + const device = createDevice(eventEmitter); + const listener = vi.fn(); + eventEmitter.on(DeviceEvent.deviceDisconnected, listener); + + await device.close(); + + expect(listener).toHaveBeenCalledWith(device); + }); + + it('logs a warning and still closes when disconnectAsync throws', async () => { + mockPeripheral.disconnectAsync.mockRejectedValue(new Error('disconnect failed')); + const device = createDevice(); + + await device.close(); + + expect(device.getState).toStrictEqual(DeviceState.closed); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('is a no-op when device is already closed', async () => { + const device = createDevice(); + await device.close(); + vi.clearAllMocks(); + + await device.close(); + + expect(mockPeripheral.disconnectAsync).not.toHaveBeenCalled(); + expect(mockPeripheral.cancelConnect).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index c67d37ad..c51f7aae 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -1,10 +1,11 @@ -import {describe, it, expect} from "vitest"; +import {describe, it, expect, beforeEach} from "vitest"; import {mock,mockClear} from "vitest-mock-extended"; -import DeviceManager, { DeviceManagerEvent } from "../../../src/device/deviceManager.js"; +import DeviceManager, { DeviceManagerEvent, DeviceInfo } from "../../../src/device/deviceManager.js"; import {EventEmitter} from "events"; import Device from "../../../src/device/device.js"; import TestDevice from "./testDevice.js"; import Logger from "../../../src/logging/Logger.js"; +import { DeviceId } from "../../../src/device/deviceId.js"; describe('deviceManager', () => { @@ -84,4 +85,206 @@ describe('deviceManager', () => { expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(2, DeviceManagerEvent.deviceDisconnected, device); expect(mockedLogger.child).toBeCalledWith({ name: DeviceManager.name }); }); + + describe('getConnectedDevice', () => { + let mockedLogger: ReturnType>; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + }); + + it('returns the device when found by uuid', () => { + const uuid = 'known-device-uuid'; + const device = mock(); + const connectedDevices = new Map([[uuid, device]]); + const manager = new DeviceManager(mock(), connectedDevices, mockedLogger); + + expect(manager.getConnectedDevice(uuid)).toBe(device); + }); + + it('returns null when device is not found', () => { + const manager = new DeviceManager(mock(), new Map(), mockedLogger); + + expect(manager.getConnectedDevice('unknown-uuid')).toBeNull(); + }); + }); + + describe('announceDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-1'); + const deviceInfo: DeviceInfo = { id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + }); + + it('emits deviceDetected event for a newly seen device', () => { + mockedEventEmitter.emit.mockReturnValue(true); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('does not re-announce a device already in the acquire queue', () => { + mockedEventEmitter.emit.mockReturnValue(true); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).toHaveBeenCalledOnce(); + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('does not emit event when device is already connected', () => { + const connectedDevices = new Map([[deviceId, mock()]]); + const manager = new DeviceManager(mockedEventEmitter, connectedDevices, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + }); + + it('removes device from queue when no listeners respond to deviceDetected', async () => { + mockedEventEmitter.emit.mockReturnValue(false); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + const result = await manager.acquireDetectedDevice(deviceId); + expect(result.successful).toBe(false); + }); + }); + + describe('acquireDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-2'); + const deviceInfo: DeviceInfo = { id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('returns failure when device is not in the detect queue', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + const result = await manager.acquireDetectedDevice(deviceId); + + expect(result.successful).toBe(false); + }); + + it('resolves immediately with success for the first caller', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + + const result = await manager.acquireDetectedDevice(deviceId); + + expect(result).toStrictEqual({ successful: true }); + }); + + it('queues the second caller until the first releases', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + + await manager.acquireDetectedDevice(deviceId); + const secondCallerPromise = manager.acquireDetectedDevice(deviceId); + manager.releaseDetectedDevice(deviceId); + + const result = await secondCallerPromise; + expect(result).toStrictEqual({ successful: true }); + }); + }); + + describe('releaseDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-3'); + const deviceInfo: DeviceInfo = { id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('is a no-op when device is not in the acquire queue', () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + expect(() => manager.releaseDetectedDevice(DeviceId.create('unknown'))).not.toThrow(); + }); + + it('removes device from queue after the only waiter releases', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + await manager.acquireDetectedDevice(deviceId); + + manager.releaseDetectedDevice(deviceId); + + const result = await manager.acquireDetectedDevice(deviceId); + expect(result.successful).toBe(false); + }); + }); + + describe('revokeDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-4'); + const deviceInfo: DeviceInfo = { id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('resolves a pending second caller with failure', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + await manager.acquireDetectedDevice(deviceId); // first caller holds + const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits + + manager.revokeDetectedDevice(deviceInfo); + + const result = await pendingPromise; + expect(result.successful).toBe(false); + }); + }); + + describe('claimDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-5'); + const deviceInfo: DeviceInfo = { id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('resolves a pending caller with failure', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + await manager.acquireDetectedDevice(deviceId); // first caller holds + const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits + + manager.claimDetectedDevice(deviceId); + + const result = await pendingPromise; + expect(result.successful).toBe(false); + }); + }); }); diff --git a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts new file mode 100644 index 00000000..7dc1b300 --- /dev/null +++ b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { Peripheral } from '@stoprocent/noble'; +import EventEmitter from 'events'; +import { DeviceId } from '../../../../../src/device/deviceId.js'; +import AiroticDevice, { AiroticDeviceAttributes } from '../../../../../src/device/protocol/airotic/airoticDevice.js'; +import AiroticProtocol from '../../../../../src/device/protocol/airotic/airtonicProtocol.js'; +import MessageResponseHandler from '../../../../../src/device/protocol/messageResponseHandler.js'; +import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAttribute.js'; +import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +vi.mock('../../../../../src/util/async.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sleep: vi.fn().mockResolvedValue(undefined) }; +}); + +describe('AiroticDevice', () => { + let mockPeripheral: ReturnType>; + let mockHandler: ReturnType>>; + let mockLogger: ReturnType>; + + function createAttributes(): AiroticDeviceAttributes { + return { + restColor: new StrDeviceAttribute('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, undefined), + breathInColor: new StrDeviceAttribute('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, undefined), + resetColors: new BoolDeviceAttribute('resetColors', 'Reset Colors', DeviceAttributeModifier.readWrite, undefined), + reboot: new BoolDeviceAttribute('reboot', 'Reboot', DeviceAttributeModifier.readWrite, undefined), + }; + } + + function createDevice(): AiroticDevice { + return new AiroticDevice( + DeviceId.create('airotic-device'), + 'Airotic', + 'airotic-provider', + mockPeripheral, + mockHandler, + new Date(), + true, + createAttributes(), + {}, + new EventEmitter(), + mockLogger, + ); + } + + beforeEach(() => { + vi.useFakeTimers(); + + mockPeripheral = mock(); + mockHandler = mock>(); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + Object.defineProperty(mockPeripheral, 'rssi', { get: () => -70, configurable: true }); + Object.defineProperty(mockPeripheral, 'state', { get: () => 'connected', configurable: true }); + + mockPeripheral.disconnectAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('setAttribute restColor', () => { + it('sends selectRestColor message followed by setColor message', async () => { + const device = createDevice(); + + await device.setAttribute('restColor', '255,0,128'); + + expect(mockHandler.send).toHaveBeenCalledTimes(2); + expect(mockHandler.send).toHaveBeenNthCalledWith(1, AiroticProtocol.createSelectRestColorMessage()); + expect(mockHandler.send).toHaveBeenNthCalledWith(2, AiroticProtocol.createSetColorMessage(255, 0, 128)); + }); + + it('returns the color value that was set', async () => { + const device = createDevice(); + + const result = await device.setAttribute('restColor', '100,200,50'); + + expect(result).toStrictEqual('100,200,50'); + }); + }); + + describe('setAttribute breathInColor', () => { + it('sends selectBreathInColor message followed by setColor message', async () => { + const device = createDevice(); + + await device.setAttribute('breathInColor', '0,100,200'); + + expect(mockHandler.send).toHaveBeenCalledTimes(2); + expect(mockHandler.send).toHaveBeenNthCalledWith(1, AiroticProtocol.createSelectBreathInColorMessage()); + expect(mockHandler.send).toHaveBeenNthCalledWith(2, AiroticProtocol.createSetColorMessage(0, 100, 200)); + }); + + it('returns the color value that was set', async () => { + const device = createDevice(); + + const result = await device.setAttribute('breathInColor', '50,50,50'); + + expect(result).toStrictEqual('50,50,50'); + }); + }); + + describe('setAttribute resetColors', () => { + it('sends resetColors message when value is true', async () => { + const device = createDevice(); + + await device.setAttribute('resetColors', true); + + expect(mockHandler.send).toHaveBeenCalledOnce(); + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createResetColorsMessage()); + }); + + it('does not send any message when value is false', async () => { + const device = createDevice(); + + await device.setAttribute('resetColors', false); + + expect(mockHandler.send).not.toHaveBeenCalled(); + }); + + it('returns the boolean value that was set', async () => { + const device = createDevice(); + + expect(await device.setAttribute('resetColors', true)).toStrictEqual(true); + expect(await device.setAttribute('resetColors', false)).toStrictEqual(false); + }); + }); + + describe('setAttribute reboot', () => { + it('sends reboot message and closes device when value is true', async () => { + const device = createDevice(); + const closeSpy = vi.spyOn(device, 'close'); + + await device.setAttribute('reboot', true); + + expect(mockHandler.send).toHaveBeenCalledOnce(); + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createRebootMessage()); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('does not send any message when value is false', async () => { + const device = createDevice(); + + await device.setAttribute('reboot', false); + + expect(mockHandler.send).not.toHaveBeenCalled(); + }); + + it('returns the boolean value that was set', async () => { + const device = createDevice(); + + expect(await device.setAttribute('reboot', false)).toStrictEqual(false); + }); + }); + + describe('setAttribute with unknown attribute', () => { + it('throws an error for unknown attribute name', async () => { + const device = createDevice(); + + await expect( + Reflect.apply(device.setAttribute, device, ['unknownAttr', 'value']), + ).rejects.toThrow("Unknown attribute 'unknownAttr' or invalid value type"); + }); + }); + + describe('color parsing', () => { + it('throws when color has wrong number of components', async () => { + const device = createDevice(); + + await expect( + device.setAttribute('restColor', '255,0'), + ).rejects.toThrow('Invalid color format: expected 3 components, got 2'); + }); + + it('throws when color has too many components', async () => { + const device = createDevice(); + + await expect( + device.setAttribute('restColor', '255,0,0,0'), + ).rejects.toThrow('Invalid color format: expected 3 components, got 4'); + }); + + it('throws when color channel value is above 255', async () => { + const device = createDevice(); + + await expect( + device.setAttribute('restColor', '255,0,300'), + ).rejects.toThrow('Invalid color channel value: 300'); + }); + + it('throws when color channel value is negative', async () => { + const device = createDevice(); + + await expect( + device.setAttribute('restColor', '255,-1,0'), + ).rejects.toThrow('Invalid color channel value: -1'); + }); + + it('throws when color channel value is not a number', async () => { + const device = createDevice(); + + await expect( + device.setAttribute('restColor', '255,abc,0'), + ).rejects.toThrow('Invalid color channel value: abc'); + }); + + it('correctly parses boundary values 0 and 255', async () => { + const device = createDevice(); + + await expect( + device.setAttribute('breathInColor', '0,255,0'), + ).resolves.toStrictEqual('0,255,0'); + + expect(mockHandler.send).toHaveBeenNthCalledWith(2, AiroticProtocol.createSetColorMessage(0, 255, 0)); + }); + }); +}); diff --git a/tests/unit/device/protocol/airotic/airoticProtocol.spec.ts b/tests/unit/device/protocol/airotic/airoticProtocol.spec.ts new file mode 100644 index 00000000..ee629328 --- /dev/null +++ b/tests/unit/device/protocol/airotic/airoticProtocol.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import AiroticProtocol from '../../../../../src/device/protocol/airotic/airtonicProtocol.js'; +import { expectToBeSuccessfulDecodeResult } from '../../../helper/protocol.js'; + +describe('AiroticProtocol', () => { + const protocol = new AiroticProtocol(); + + describe('encode', () => { + it('returns the same buffer that was passed in', () => { + const input = Buffer.from('!H', 'utf8'); + + const result = protocol.encode(input); + + expect(result).toBe(input); + }); + }); + + describe('decode', () => { + it('decodes buffer to a utf-8 string message', () => { + const input = Buffer.from('Hello I am bottle v2.0', 'utf8'); + + const result = protocol.decode(input); + + expectToBeSuccessfulDecodeResult(result); + expect(result.message).toStrictEqual('Hello I am bottle v2.0'); + }); + }); + + describe('isResponseMatchingMessage', () => { + it('matches the hello message when response starts with the hello prefix', () => { + const msg = AiroticProtocol.createHelloMessage(); + + expect(protocol.isResponseMatchingMessage('Hello I am bottle v2.0', msg)).toBe(true); + }); + + it('does not match the hello message when response has a different prefix', () => { + const msg = AiroticProtocol.createHelloMessage(); + + expect(protocol.isResponseMatchingMessage('Unknown response', msg)).toBe(false); + }); + + it('does not match when message is not the hello message', () => { + const msg = AiroticProtocol.createSetColorMessage(255, 0, 0); + + expect(protocol.isResponseMatchingMessage('Hello I am bottle v2.0', msg)).toBe(false); + }); + }); + + describe('createHelloMessage', () => { + it('returns a !H buffer with responseType field (MessageWithResponse)', () => { + const msg = AiroticProtocol.createHelloMessage(); + + expect(msg.message).toStrictEqual(Buffer.from('!H', 'utf8')); + expect('responseType' in msg).toBe(true); + expect(msg.responseType).toBeUndefined(); + }); + }); + + describe('createSelectRestColorMessage', () => { + it('returns a !B1 buffer without responseType (fire-and-forget)', () => { + const msg = AiroticProtocol.createSelectRestColorMessage(); + + expect(msg.message).toStrictEqual(Buffer.from('!B1', 'utf8')); + expect('responseType' in msg).toBe(false); + }); + }); + + describe('createSelectBreathInColorMessage', () => { + it('returns a !B2 buffer without responseType (fire-and-forget)', () => { + const msg = AiroticProtocol.createSelectBreathInColorMessage(); + + expect(msg.message).toStrictEqual(Buffer.from('!B2', 'utf8')); + expect('responseType' in msg).toBe(false); + }); + }); + + describe('createSetColorMessage', () => { + it('returns !C prefix followed by the raw RGB bytes', () => { + const msg = AiroticProtocol.createSetColorMessage(10, 128, 255); + + const expected = Buffer.concat([Buffer.from('!C', 'utf8'), Buffer.from([10, 128, 255])]); + expect(msg.message).toStrictEqual(expected); + expect('responseType' in msg).toBe(false); + }); + + it('handles boundary values 0 and 255 correctly', () => { + const msg = AiroticProtocol.createSetColorMessage(0, 255, 0); + + expect(msg.message[2]).toBe(0); + expect(msg.message[3]).toBe(255); + expect(msg.message[4]).toBe(0); + }); + }); + + describe('createResetColorsMessage', () => { + it('returns a !B3 buffer without responseType', () => { + const msg = AiroticProtocol.createResetColorsMessage(); + + expect(msg.message).toStrictEqual(Buffer.from('!B3', 'utf8')); + expect('responseType' in msg).toBe(false); + }); + }); + + describe('createRebootMessage', () => { + it('returns a !B4 buffer without responseType', () => { + const msg = AiroticProtocol.createRebootMessage(); + + expect(msg.message).toStrictEqual(Buffer.from('!B4', 'utf8')); + expect('responseType' in msg).toBe(false); + }); + }); +}); diff --git a/tests/unit/device/protocol/estim2b/estim2bProtocol.spec.ts b/tests/unit/device/protocol/estim2b/estim2bProtocol.spec.ts new file mode 100644 index 00000000..d3c1b460 --- /dev/null +++ b/tests/unit/device/protocol/estim2b/estim2bProtocol.spec.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; +import EStim2bProtocol, { EStim2bMode, EStim2bStatus } from '../../../../../src/device/protocol/estim2b/estim2bProtocol.js'; +import { expectToBeErrorDecodeResult, expectToBeSuccessfulDecodeResult } from '../../../helper/protocol.js'; + +describe('EStim2bProtocol', () => { + const protocol = new EStim2bProtocol(); + + function makeValidRawResponse(overrides: Partial<{ + batteryLevel: number; + channelARaw: number; + channelBRaw: number; + pulseFrequencyRaw: number; + pulsePwmRaw: number; + currentMode: number; + powerMode: string; + channelsJoinedRaw: number; + firmwareVersion: string; + }> = {}): string { + const v = { + batteryLevel: 600, + channelARaw: 20, + channelBRaw: 10, + pulseFrequencyRaw: 50, + pulsePwmRaw: 50, + currentMode: EStim2bMode.pulse, + powerMode: 'L', + channelsJoinedRaw: 0, + firmwareVersion: '1.0.0', + ...overrides, + }; + return `${v.batteryLevel}:${v.channelARaw}:${v.channelBRaw}:${v.pulseFrequencyRaw}:${v.pulsePwmRaw}:${v.currentMode}:${v.powerMode}:${v.channelsJoinedRaw}:${v.firmwareVersion}`; + } + + describe('encode', () => { + it('encodes a command string to a UTF-8 buffer', () => { + const result = protocol.encode('M5'); + + expect(result).toBeInstanceOf(Buffer); + expect(result.toString('utf-8')).toStrictEqual('M5'); + }); + + it('encodes empty status request command', () => { + const result = protocol.encode(''); + + expect(result.toString('utf-8')).toStrictEqual(''); + }); + }); + + describe('decode', () => { + it('parses a valid 9-part response into the correct EStim2bStatus object', () => { + const raw = makeValidRawResponse(); + + const result = protocol.decode(Buffer.from(raw)); + + expectToBeSuccessfulDecodeResult(result); + const status: EStim2bStatus = result.message; + expect(status.batteryLevel).toBe(600); + expect(status.channelALevel).toBe(10); // 20 / 2 + expect(status.channelBLevel).toBe(5); // 10 / 2 + expect(status.pulseFrequency).toBe(25); // 50 / 2 + expect(status.pulsePwm).toBe(25); // 50 / 2 + expect(status.currentMode).toBe(EStim2bMode.pulse); + expect(status.powerMode).toBe('L'); + expect(status.channelsJoined).toBe(false); + expect(status.firmwareVersion).toBe('1.0.0'); + }); + + it('channels joined is true when raw value is 1', () => { + const raw = makeValidRawResponse({ channelsJoinedRaw: 1 }); + + const result = protocol.decode(Buffer.from(raw)); + + expectToBeSuccessfulDecodeResult(result); + expect(result.message.channelsJoined).toBe(true); + }); + + it('returns error when response has fewer than 9 parts', () => { + const result = protocol.decode(Buffer.from('600:20:10:50:50:0:L:0')); + + expectToBeErrorDecodeResult(result); + expect(result.error.type).toStrictEqual('invalid_frame'); + }); + + it('returns error when response has more than 9 parts', () => { + const result = protocol.decode(Buffer.from('600:20:10:50:50:0:L:0:1.0.0:extra')); + + expectToBeErrorDecodeResult(result); + expect(result.error.type).toStrictEqual('invalid_frame'); + }); + + it('returns error when a numeric field is not a number', () => { + const raw = makeValidRawResponse({ channelARaw: 'abc' as unknown as number }); + + const result = protocol.decode(Buffer.from(raw)); + + expectToBeErrorDecodeResult(result); + expect(result.error.type).toStrictEqual('invalid_frame'); + }); + + it('returns error when channelsJoined is not 0 or 1', () => { + const raw = makeValidRawResponse({ channelsJoinedRaw: 2 }); + + const result = protocol.decode(Buffer.from(raw)); + + expectToBeErrorDecodeResult(result); + expect(result.error.type).toStrictEqual('invalid_frame'); + }); + }); + + describe('isResponseMatchingMessage', () => { + it('always returns true regardless of response content', () => { + expect(protocol.isResponseMatchingMessage()).toBe(true); + }); + }); + + describe('createGetStatusCommand', () => { + it('returns an empty string', () => { + expect(protocol.createGetStatusCommand()).toStrictEqual(''); + }); + }); + + describe('createSetModeCommand', () => { + it('returns M prefix followed by the mode number', () => { + expect(protocol.createSetModeCommand(EStim2bMode.bounce)).toStrictEqual('M1'); + }); + + it('throws when mode is out of range', () => { + expect(() => protocol.createSetModeCommand(17)).toThrow('Mode must be an integer between 0 and 16'); + }); + + it('throws when mode is negative', () => { + expect(() => protocol.createSetModeCommand(-1)).toThrow('Mode must be an integer between 0 and 16'); + }); + + it('throws for non-integer values', () => { + expect(() => protocol.createSetModeCommand(1.5)).toThrow('Mode must be an integer between 0 and 16'); + }); + }); + + describe('createSetPowerModeCommand', () => { + it('returns H for high power mode', () => { + expect(protocol.createSetPowerModeCommand('H')).toStrictEqual('H'); + }); + + it('returns L for low power mode', () => { + expect(protocol.createSetPowerModeCommand('L')).toStrictEqual('L'); + }); + }); + + describe('createSetPowerCommand', () => { + it('returns channel letter followed by percentage number', () => { + expect(protocol.createSetPowerCommand('A', 50)).toStrictEqual('A50'); + expect(protocol.createSetPowerCommand('B', 0)).toStrictEqual('B0'); + }); + + it('throws when percentage exceeds 99', () => { + expect(() => protocol.createSetPowerCommand('A', 100)).toThrow('Percentage must be an integer between 0 and 99'); + }); + + it('throws when percentage is negative', () => { + expect(() => protocol.createSetPowerCommand('A', -1)).toThrow('Percentage must be an integer between 0 and 99'); + }); + }); + + describe('createSetPulsePwmCommand', () => { + it('returns D prefix followed by the pulsePwm value', () => { + expect(protocol.createSetPulsePwmCommand(50)).toStrictEqual('D50'); + }); + + it('throws when value is below the minimum of 2', () => { + expect(() => protocol.createSetPulsePwmCommand(1)).toThrow('Pulse PWM must be an integer between 2 and 100'); + }); + + it('throws when value exceeds 100', () => { + expect(() => protocol.createSetPulsePwmCommand(101)).toThrow('Pulse PWM must be an integer between 2 and 100'); + }); + }); + + describe('createSetPulseFrequencyCommand', () => { + it('returns C prefix followed by the frequency value', () => { + expect(protocol.createSetPulseFrequencyCommand(40)).toStrictEqual('C40'); + }); + + it('throws when value is below the minimum of 2', () => { + expect(() => protocol.createSetPulseFrequencyCommand(1)).toThrow('Pulse frequency must be an integer between 2 and 100'); + }); + + it('throws when value exceeds 100', () => { + expect(() => protocol.createSetPulseFrequencyCommand(101)).toThrow('Pulse frequency must be an integer between 2 and 100'); + }); + }); + + describe('createPowerZeroCommand', () => { + it('returns K', () => { + expect(protocol.createPowerZeroCommand()).toStrictEqual('K'); + }); + }); + + describe('createResetCommand', () => { + it('returns E', () => { + expect(protocol.createResetCommand()).toStrictEqual('E'); + }); + }); +}); diff --git a/tests/unit/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.spec.ts b/tests/unit/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.spec.ts index fd319dec..abfd08ee 100644 --- a/tests/unit/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.spec.ts +++ b/tests/unit/device/protocol/slvCtrlPlus/slvCtrlProtocolLegacy.spec.ts @@ -7,8 +7,8 @@ import IntDeviceAttribute from "../../../../../src/device/attribute/intDeviceAtt import ListDeviceAttribute from "../../../../../src/device/attribute/listDeviceAttribute.js"; import StrDeviceAttribute from "../../../../../src/device/attribute/strDeviceAttribute.js"; import FloatDeviceAttribute from "../../../../../src/device/attribute/floatDeviceAttribute.js"; -import { expectToBeSuccessfulDecodeResult } from '../../../helper/protocol.js'; -import { SlvCtrlProtocolCommand } from '../../../../../src/device/protocol/slvCtrlPlus/slvCtrlProtocol.js'; +import { expectToBeErrorDecodeResult, expectToBeSuccessfulDecodeResult } from '../../../helper/protocol.js'; +import { SlvCtrlProtocolCommand, SlvCtrlProtocolResponse } from '../../../../../src/device/protocol/slvCtrlPlus/slvCtrlProtocol.js'; describe('slvCtrlProtocolLegacy', () => { @@ -187,4 +187,74 @@ describe('slvCtrlProtocolLegacy', () => { // Assert expect(result.message.data).toStrictEqual({}); }); + + it('it returns an error when a mandatory segment is missing', () => { + const protocol = new SlvCtrlProtocolLegacy(); + + const result = protocol.decode(Buffer.from('onlyCommandNoSemicolon')); + + expectToBeErrorDecodeResult(result); + expect(result.error).toStrictEqual({ type: 'invalid_frame', reason: 'Mandatory segment missing' }); + }); + + it('it parses set attribute response with value key', () => { + const protocol = new SlvCtrlProtocolLegacy(); + + const result = protocol.decode(Buffer.from('set-mode;42')); + + expectToBeSuccessfulDecodeResult(result); + expect(result.message.command).toStrictEqual('set-mode'); + expect(result.message.data).toStrictEqual({ value: '42' }); + }); + + it('it parses introduce response with type, fw, and protocol keys', () => { + const protocol = new SlvCtrlProtocolLegacy(); + + const result = protocol.decode(Buffer.from('introduce;myDevice,1000,2')); + + expectToBeSuccessfulDecodeResult(result); + expect(result.message.command).toStrictEqual('introduce'); + expect(result.message.data).toStrictEqual({ type: 'myDevice', fw: '1000', protocol: '2' }); + }); + + it('it parses the result section for error responses', () => { + const protocol = new SlvCtrlProtocolLegacy(); + + const result = protocol.decode(Buffer.from('set-mode;42;error,Not found')); + + expectToBeSuccessfulDecodeResult(result); + expect(result.message.result).toStrictEqual({ status: 'error', reason: 'Not found' }); + }); + + describe('isResponseMatchingMessage', () => { + it('returns true when response command matches the encoded message', () => { + const protocol = new SlvCtrlProtocolLegacy(); + const message = { + message: { command: 'status', args: [] } satisfies SlvCtrlProtocolCommand, + responseType: undefined, + }; + const response: SlvCtrlProtocolResponse = { + command: 'status', + data: {}, + result: { status: 'ok' }, + }; + + expect(protocol.isResponseMatchingMessage(response, message)).toBe(true); + }); + + it('returns false when response command does not match the encoded message', () => { + const protocol = new SlvCtrlProtocolLegacy(); + const message = { + message: { command: 'status', args: [] } satisfies SlvCtrlProtocolCommand, + responseType: undefined, + }; + const response: SlvCtrlProtocolResponse = { + command: 'attributes', + data: {}, + result: { status: 'ok' }, + }; + + expect(protocol.isResponseMatchingMessage(response, message)).toBe(false); + }); + }); }); diff --git a/tests/unit/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.spec.ts b/tests/unit/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.spec.ts index d70fc4cf..2b8edd39 100644 --- a/tests/unit/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.spec.ts +++ b/tests/unit/device/protocol/slvCtrlPlus/slvCtrlProtocolV1.spec.ts @@ -205,4 +205,54 @@ describe('slvCtrlProtocolV1', () => { expectToBeSuccessfulDecodeResult(result); expect(result.message.data).toStrictEqual({}); }); + + it('it returns an error when the result segment has no status key', () => { + const protocol = new SlvCtrlProtocolV1(); + + const result = protocol.decode(Buffer.from('status;foo:bar;notastatus')); + + expectToBeErrorDecodeResult(result); + expect(result.error).toStrictEqual({ type: 'invalid_frame', reason: 'Result segment is malformed' }); + }); + + describe('isResponseMatchingMessage', () => { + it('returns true when response command matches the encoded message', () => { + const protocol = new SlvCtrlProtocolV1(); + const message = { + message: { command: 'status', args: [] } satisfies SlvCtrlProtocolCommand, + responseType: undefined, + }; + const response = { + command: 'status', + data: {}, + result: { status: 'ok' as const }, + }; + + expect(protocol.isResponseMatchingMessage(response, message)).toBe(true); + }); + + it('returns false when response command does not match the encoded message', () => { + const protocol = new SlvCtrlProtocolV1(); + const message = { + message: { command: 'status', args: [] } satisfies SlvCtrlProtocolCommand, + responseType: undefined, + }; + const response = { + command: 'attributes', + data: {}, + result: { status: 'ok' as const }, + }; + + expect(protocol.isResponseMatchingMessage(response, message)).toBe(false); + }); + }); + + describe('getAttributes', () => { + it('throws when an attribute has an unknown data type', () => { + const protocol = new SlvCtrlProtocolV1(); + + expect(() => protocol.getAttributes({ myAttr: 'rw[unknownType]' })) + .toThrow('Unknown attribute data type: unknownType'); + }); + }); }); diff --git a/tests/unit/device/protocol/zc95/zc95Protocol.spec.ts b/tests/unit/device/protocol/zc95/zc95Protocol.spec.ts new file mode 100644 index 00000000..02411678 --- /dev/null +++ b/tests/unit/device/protocol/zc95/zc95Protocol.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import Zc95Protocol from '../../../../../src/device/protocol/zc95/zc95Protocol.js'; +import { expectToBeErrorDecodeResult, expectToBeSuccessfulDecodeResult } from '../../../helper/protocol.js'; + +describe('Zc95Protocol', () => { + + const protocol = new Zc95Protocol(); + + describe('encode', () => { + it('serializes the message object to a JSON Buffer', () => { + const msg = { Type: 'PatternList', MsgId: 3, Data: 'foo' }; + + const result = protocol.encode(msg); + + expect(result).toBeInstanceOf(Buffer); + expect(JSON.parse(result.toString('utf-8'))).toStrictEqual(msg); + }); + }); + + describe('decode', () => { + it('parses a valid JSON buffer to a message object', () => { + const obj = { Type: 'Ack', MsgId: 1, Result: 'OK' }; + + const result = protocol.decode(Buffer.from(JSON.stringify(obj))); + + expectToBeSuccessfulDecodeResult(result); + expect(result.message).toStrictEqual(obj); + }); + + it('returns an error result for invalid JSON', () => { + const result = protocol.decode(Buffer.from('not-valid-json{{')); + + expectToBeErrorDecodeResult(result); + expect(result.error.type).toStrictEqual('invalid_frame'); + }); + }); + + describe('isResponseMatchingMessage', () => { + it('returns true when MsgId and Type match the responseIdentifier', () => { + const msg = Zc95Protocol.createMessage({ Type: 'DoSomething', MsgId: 7 }, 'Ack'); + const response = { Type: 'Ack', MsgId: 7, Result: 'OK' as const }; + + expect(protocol.isResponseMatchingMessage(response, msg)).toBe(true); + }); + + it('returns false when MsgId does not match', () => { + const msg = Zc95Protocol.createMessage({ Type: 'DoSomething', MsgId: 7 }, 'Ack'); + const response = { Type: 'Ack', MsgId: 8, Result: 'OK' as const }; + + expect(protocol.isResponseMatchingMessage(response, msg)).toBe(false); + }); + + it('returns false when Type does not match', () => { + const msg = Zc95Protocol.createMessage({ Type: 'DoSomething', MsgId: 7 }, 'Ack'); + const response = { Type: 'PatternDetail', MsgId: 7, Result: 'OK' as const }; + + expect(protocol.isResponseMatchingMessage(response, msg)).toBe(false); + }); + }); + + describe('createMessage', () => { + it('creates a message with the correct fields and responseIdentifier', () => { + const innerMsg = { Type: 'PatternList', MsgId: 3 }; + + const result = Zc95Protocol.createMessage(innerMsg, 'PatternListData'); + + expect(result.message).toStrictEqual(innerMsg); + expect(result.responseType).toBeUndefined(); + expect(result.responseIdentifier).toStrictEqual({ msgId: 3, type: 'PatternListData' }); + }); + }); +}); From 27c18151f3ccbeed4d51b631e06cda2bbbff1dc2 Mon Sep 17 00:00:00 2001 From: HRS Date: Thu, 2 Apr 2026 08:25:44 +0200 Subject: [PATCH 26/42] Add observer tests --- .../unit/device/transport/bleObserver.spec.ts | 193 ++++++++++++++++++ .../transport/serialPortObserver.spec.ts | 185 +++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 tests/unit/device/transport/bleObserver.spec.ts create mode 100644 tests/unit/device/transport/serialPortObserver.spec.ts diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts new file mode 100644 index 00000000..64fbe2f7 --- /dev/null +++ b/tests/unit/device/transport/bleObserver.spec.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { Peripheral } from '@stoprocent/noble'; +import DeviceManager from '../../../../src/device/deviceManager.js'; +import Logger from '../../../../src/logging/Logger.js'; +import BleObserver from '../../../../src/device/transport/bleObserver.js'; +import { DeviceId } from '../../../../src/device/deviceId.js'; + +const mockNoble = vi.hoisted(() => ({ + on: vi.fn(), + waitForPoweredOnAsync: vi.fn(), + startScanningAsync: vi.fn(), + stopScanningAsync: vi.fn(), +})); + +vi.mock('@stoprocent/noble', () => ({ default: mockNoble })); + +describe('BleObserver', () => { + let mockDeviceManager: ReturnType>; + let mockLogger: ReturnType>; + + function createObserver(): BleObserver { + return new BleObserver(mockDeviceManager, mockLogger); + } + + function getNobleListener(event: string) { + return mockNoble.on.mock.calls.find(([e]) => e === event)?.[1]; + } + + beforeEach(() => { + vi.resetAllMocks(); + + mockDeviceManager = mock(); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + mockNoble.waitForPoweredOnAsync.mockResolvedValue(undefined); + mockNoble.startScanningAsync.mockResolvedValue(undefined); + mockNoble.stopScanningAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('creates a child logger with the observer class name', () => { + createObserver(); + + expect(mockLogger.child).toHaveBeenCalledWith({ name: BleObserver.name }); + }); + }); + + describe('init', () => { + it('registers a discover listener on noble', async () => { + const observer = createObserver(); + + await observer.init(); + + expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); + }); + + it('registers a stateChange listener on noble', async () => { + const observer = createObserver(); + + await observer.init(); + + expect(mockNoble.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + }); + + it('registers a scanStop listener on noble', async () => { + const observer = createObserver(); + + await observer.init(); + + expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); + }); + + it('calls waitForPoweredOnAsync and startScanningAsync with the UART UUID', async () => { + const observer = createObserver(); + + await observer.init(); + + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + expect(mockNoble.startScanningAsync).toHaveBeenCalledWith( + ['6e400001b5a3f393e0a9e50e24dcca9e'], + true, + ); + }); + + it('does not call startScanningAsync a second time when stateChange poweredOn fires', async () => { + const observer = createObserver(); + await observer.init(); + + getNobleListener('stateChange')?.('poweredOn'); + + // observe() returns early because isScanning is already true + expect(mockNoble.startScanningAsync).toHaveBeenCalledTimes(1); + }); + + it('does not call observe when stateChange fires with a non-poweredOn state', async () => { + const observer = createObserver(); + await observer.init(); + mockNoble.waitForPoweredOnAsync.mockClear(); + + getNobleListener('stateChange')?.('poweredOff'); + + expect(mockNoble.waitForPoweredOnAsync).not.toHaveBeenCalled(); + }); + + it('calls stopScanningAsync and allows retry when waitForPoweredOnAsync rejects', async () => { + mockNoble.waitForPoweredOnAsync.mockRejectedValue(new Error('BLE unavailable')); + const observer = createObserver(); + + await expect(observer.init()).resolves.not.toThrow(); + + expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('logs info when scanStop event fires', async () => { + const observer = createObserver(); + await observer.init(); + + getNobleListener('scanStop')?.(); + + expect(mockLogger.info).toHaveBeenCalledWith('Noble scanning stopped'); + }); + }); + + describe('onDiscover (via discover event)', () => { + function createPeripheral(rssi: number, id: string): ReturnType> { + const peripheral = mock(); + Object.defineProperty(peripheral, 'rssi', { get: () => rssi, configurable: true }); + Object.defineProperty(peripheral, 'id', { get: () => id, configurable: true }); + return peripheral; + } + + it('ignores a peripheral whose RSSI is below the minimum threshold', async () => { + const observer = createObserver(); + await observer.init(); + + getNobleListener('discover')?.(createPeripheral(-80, 'weak-device')); + + expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); + }); + + it('announces a peripheral whose RSSI is exactly at the minimum threshold (-70)', async () => { + const observer = createObserver(); + await observer.init(); + const peripheral = createPeripheral(-70, 'at-threshold'); + + getNobleListener('discover')?.(peripheral); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ peripheral }), + ); + }); + + it('announces a peripheral whose RSSI is above the minimum threshold', async () => { + const observer = createObserver(); + await observer.init(); + const peripheral = createPeripheral(-50, 'strong-device'); + + getNobleListener('discover')?.(peripheral); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); + }); + + it('uses the peripheral id to build the DeviceId passed to announceDetectedDevice', async () => { + const observer = createObserver(); + await observer.init(); + const peripheral = createPeripheral(-60, 'abc-123'); + + getNobleListener('discover')?.(peripheral); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('abc-123') }), + ); + }); + + it('logs a debug message when ignoring a weak-signal peripheral', async () => { + const observer = createObserver(); + await observer.init(); + + getNobleListener('discover')?.(createPeripheral(-80, 'noisy-device')); + + expect(mockLogger.debug).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts new file mode 100644 index 00000000..0a29bc17 --- /dev/null +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { SerialPort } from 'serialport'; +import DeviceManager from '../../../../src/device/deviceManager.js'; +import Logger from '../../../../src/logging/Logger.js'; +import SerialPortObserver from '../../../../src/device/transport/serialPortObserver.js'; +import { DeviceId } from '../../../../src/device/deviceId.js'; + +type PortInfoLike = { + path: string; + manufacturer: string | undefined; + serialNumber: string | undefined; + pnpId: string | undefined; + locationId: string | undefined; + productId: string | undefined; + vendorId: string | undefined; +}; + +describe('SerialPortObserver', () => { + let mockDeviceManager: ReturnType>; + let mockLogger: ReturnType>; + + function makePortInfo(overrides: Partial & { path: string }): PortInfoLike { + return { + manufacturer: undefined, + serialNumber: undefined, + pnpId: undefined, + locationId: undefined, + productId: undefined, + vendorId: undefined, + ...overrides, + }; + } + + function createObserver(): SerialPortObserver { + return new SerialPortObserver(mockDeviceManager, mockLogger); + } + + beforeEach(() => { + vi.useFakeTimers(); + + mockDeviceManager = mock(); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('creates a child logger with the observer class name', () => { + createObserver(); + + expect(mockLogger.child).toHaveBeenCalledWith({ name: SerialPortObserver.name }); + }); + }); + + describe('init', () => { + it('does not announce any device when no serial ports are found', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + + await observer.init(); + + expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); + }); + + it('skips a port that has no vendorId', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([ + makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: undefined, productId: '6001' }), + ]); + const observer = createObserver(); + + await observer.init(); + + expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); + }); + + it('skips a port that has no productId', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([ + makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: '0403', productId: undefined }), + ]); + const observer = createObserver(); + + await observer.init(); + + expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); + }); + + it('announces a newly discovered port to the device manager', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const observer = createObserver(); + + await observer.init(); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN001'), portInfo: port }), + ); + }); + + it('generates a synthetic serial number when serialNumber is undefined', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: undefined, vendorId: '0403', productId: '6001', locationId: 'port1' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const observer = createObserver(); + + await observer.init(); + + const expectedSn = 'serial-0403-6001-port1'; + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create(expectedSn) }), + ); + }); + + it('does not re-announce a device that is already managed on the next discovery run', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const observer = createObserver(); + + await observer.init(); + await vi.advanceTimersByTimeAsync(3000); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); + }); + + it('revokes a device that disappears from the port list', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list') + .mockResolvedValueOnce([port]) + .mockResolvedValueOnce([]); + const observer = createObserver(); + + await observer.init(); + await vi.advanceTimersByTimeAsync(3000); + + expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledOnce(); + expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN001') }), + ); + }); + + it('announces a previously revoked device again when it reappears', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list') + .mockResolvedValueOnce([port]) // first run: present + .mockResolvedValueOnce([]) // second run: gone – revoked + .mockResolvedValueOnce([port]); // third run: back + const observer = createObserver(); + + await observer.init(); + await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(3000); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); + }); + + it('handles a SerialPort.list error gracefully without throwing', async () => { + vi.spyOn(SerialPort, 'list').mockRejectedValue(new Error('USB stack error')); + const observer = createObserver(); + + await expect(observer.init()).resolves.not.toThrow(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('handles multiple valid ports in a single discovery run', async () => { + const port1 = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + const port2 = makePortInfo({ path: '/dev/ttyUSB1', serialNumber: 'SN002', vendorId: '0403', productId: '6015' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port1, port2]); + const observer = createObserver(); + + await observer.init(); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN001') }), + ); + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN002') }), + ); + }); + }); +}); From 92ee84f4d9bfe7b356d6f07d82c88559b72544a6 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 24 May 2026 13:55:32 +0200 Subject: [PATCH 27/42] AI enhancements --- .gitignore | 2 + docs/adr/0001-ble-transport-architecture.md | 48 +++++++ src/device/bleDevice.ts | 23 ++- src/device/deviceManager.ts | 1 + src/device/protocol/airotic/airoticDevice.ts | 24 +++- .../protocol/airotic/airoticDeviceProvider.ts | 131 ++++-------------- src/device/provider/bleDeviceProvider.ts | 81 +++++++++++ src/device/provider/serialDeviceProvider.ts | 2 +- src/device/transport/bleDeviceTransport.ts | 9 ++ src/device/transport/bleObserver.ts | 2 + src/device/transport/serialPortObserver.ts | 2 + tests/unit/device/bleDevice.spec.ts | 47 ++++++- .../protocol/airotic/airoticDevice.spec.ts | 71 +++++++++- .../unit/device/transport/bleObserver.spec.ts | 2 +- 14 files changed, 337 insertions(+), 108 deletions(-) create mode 100644 docs/adr/0001-ble-transport-architecture.md create mode 100644 src/device/provider/bleDeviceProvider.ts diff --git a/.gitignore b/.gitignore index a44d847a..616fc79a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ node_modules/ yarn-error.log .env +CLAUDE.md +CONTEXT.md diff --git a/docs/adr/0001-ble-transport-architecture.md b/docs/adr/0001-ble-transport-architecture.md new file mode 100644 index 00000000..b4514407 --- /dev/null +++ b/docs/adr/0001-ble-transport-architecture.md @@ -0,0 +1,48 @@ +# ADR 0001 — BLE Transport Architecture + +**Status**: Accepted +**Date**: 2026-05-09 + +## Context + +Adding BLE support introduced several design decisions that affect how BLE and serial transports coexist in the device layer. + +## Decisions + +### 1. Device owns its Transport lifecycle + +**Decision**: A `BleDevice` holds a reference to its `BleUartDeviceTransport` and closes it in `doClose()`, mirroring how `PeripheralDevice` closes its serial transport. The provider does not manage transport cleanup via event listeners. + +**Alternatives considered**: Have the `BleDeviceProvider` listen for `DeviceEvent.deviceDisconnected` and close the transport there. This was the initial implementation but creates an implicit contract: if the device is ever closed without the provider's listener being registered, the transport leaks. + +**Why**: Ownership of the transport belongs to the device, not to the provider. The type system can enforce this; event listeners cannot. + +--- + +### 2. `DeviceInfo` uses a discriminant union, not structural duck typing + +**Decision**: `DeviceInfo` is a tagged union `{ type: 'ble'; ... } | { type: 'serial'; ... }`. Providers filter on `deviceInfo.type`, not on presence of fields like `'peripheral' in deviceInfo`. + +**Alternatives considered**: Keep the structural extension pattern (`BleDeviceInfo extends DeviceInfo`). This works but TypeScript cannot enforce that a provider has narrowed the type before accessing transport-specific fields, so a provider that forgets to filter silently receives wrong-transport detections. + +**Why**: The discriminant makes wrong-transport handling a compile error, not a runtime bug. Every provider is forced to handle the union explicitly. + +--- + +### 3. `BleDeviceProvider` abstract base class + +**Decision**: Introduce `BleDeviceProvider` as an abstract base class analogous to `SerialDeviceProvider`. It handles: the `deviceDetected` listener, the `type === 'ble'` guard, the acquire → transport → `addDevice` → claim flow, and peripheral disconnect-on-failure cleanup. Concrete BLE protocol providers implement a single abstract method (e.g. `connectBleDevice`). + +**Alternatives considered**: Leave `AiroticDeviceProvider` standalone. Acceptable for a single BLE protocol, but since more BLE protocols are planned, the acquire/transport/claim plumbing would be duplicated in each one. + +**Why**: The pattern is already proven by `SerialDeviceProvider`. Extracting it now costs little and prevents duplication as BLE protocols accumulate. + +--- + +### 4. BLE reconnect hides from the Operator; state is reconciled on reconnect, not buffered in order + +**Decision**: A BLE Device must appear continuously connected during a peripheral-level reconnect. No `deviceDisconnected` / `deviceConnected` lifecycle events are emitted during a reconnect attempt — only when reconnect finally fails. After a successful reconnect, `BleDevice` calls an abstract `syncState()` method that the concrete device implements. `syncState()` re-sends the current value of every `rw` Device Attribute to the hardware. `wo` attributes (one-shot triggers such as `reboot`) are never replayed. + +**Alternatives considered**: Buffer raw bytes in `BleUartDeviceTransport` and replay them in order after reconnect. Rejected because replaying a sequence of commands into a freshly-reconnected device that may not be in the assumed prior state is fragile, and `MessageResponseHandler` would receive stale or mismatched responses. + +**Why**: For attribute-style control, intermediate states are not meaningful — only the final desired state matters. Reconciling by replaying the current attribute values is safe, idempotent, and correct even if multiple writes happened during the disconnect window. diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 6505789b..8587dee6 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -8,6 +8,13 @@ import { DeviceId } from './deviceId.js'; import { logError } from '../util/error.js'; import Logger from '../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../util/async.js'; +import BleUartDeviceTransport from './transport/bleDeviceTransport.js'; + +export type InferBleDeviceAttributes> = + D extends BleDevice ? TAttrs : DeviceAttributes; + +export type InferBleDeviceConfig> = + D extends BleDevice ? TCfg : AnyDeviceConfig; export default abstract class BleDevice< TAttributes extends DeviceAttributes = DeviceAttributes, @@ -16,6 +23,8 @@ export default abstract class BleDevice< { private readonly peripheral: Peripheral; + private readonly transport: BleUartDeviceTransport; + private readonly rssiInterval: NodeJS.Timeout; private readonly reconnectHandler: () => void; @@ -29,6 +38,7 @@ export default abstract class BleDevice< deviceName: string, provider: string, peripheral: Peripheral, + transport: BleUartDeviceTransport, connectedSince: Date, controllable: boolean, attributes: TAttributes, @@ -41,8 +51,15 @@ export default abstract class BleDevice< this.logger = logger.child({ name: this.constructor.name }); this.peripheral = peripheral; + this.transport = transport; this.rssi = peripheral.rssi; + transport.onConnected(() => { + this.syncState().catch( + (e: unknown) => logError(this.logger, `Error syncing state after reconnect for device ${this.deviceId}`, e) + ); + }); + this.rssiInterval = setInterval(asyncHandler( async () => await this.requestRssiUpdate(), (e: unknown) => logError(this.logger,`Error during RSSI update for device ${this.deviceId}`, e) @@ -70,6 +87,8 @@ export default abstract class BleDevice< this.peripheral.on('disconnect', this.reconnectHandler); } + protected abstract syncState(): Promise; + private async requestRssiUpdate(): Promise { if (this.peripheral.state === 'disconnected') { return; @@ -88,6 +107,8 @@ export default abstract class BleDevice< } protected override async doClose(): Promise { + await this.transport.close(); + if (this.peripheral.state === 'connected') { try { await promiseWithTimeout(this.peripheral.disconnectAsync(), 750, `Timed out (>750ms) while disconnecting from device ${this.deviceId}`); @@ -101,4 +122,4 @@ export default abstract class BleDevice< clearInterval(this.rssiInterval); this.peripheral.off('disconnect', this.reconnectHandler); } -} \ No newline at end of file +} diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index a094e8b6..48da5c17 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -8,6 +8,7 @@ import { logError } from '../util/error.js'; import { DeviceId } from './deviceId.js'; export type DeviceInfo = { + type: string; id: DeviceId; }; diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index e7ed905b..b1271f63 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -11,6 +11,7 @@ import MessageResponseHandler from '../messageResponseHandler.js'; import AiroticProtocol from './airtonicProtocol.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import { sleep } from '../../../util/async.js'; +import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; export type AiroticDeviceAttributes = { restColor: StrDeviceAttribute, @@ -29,6 +30,7 @@ export default class AiroticDevice extends BleDevice, connectedSince: Date, controllable: boolean, @@ -37,17 +39,36 @@ export default class AiroticDevice extends BleDevice { + const { restColor, breathInColor } = this.attributes; + + if (restColor.value) { + const { r, g, b } = this.parseColor(restColor.value); + await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); + await sleep(100); + await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); + } + + if (breathInColor.value) { + const { r, g, b } = this.parseColor(breathInColor.value); + await this.messageResponseHandler.send(AiroticProtocol.createSelectBreathInColorMessage()); + await sleep(100); + await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); + } + } + public async setAttribute, V extends AttributeValueOf>(attributeName: K, value: V): Promise { if (attributeName === 'restColor' && value !== null && typeof value === 'string') { const { r, g, b } = this.parseColor(value); await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); await sleep(100); await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); + this.attributes.restColor.value = value; return value; } @@ -56,6 +77,7 @@ export default class AiroticDevice extends BleDevice { public static readonly providerName = 'airotic'; - private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; private static readonly UART_RX_CHAR_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; private static readonly UART_TX_CHAR_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; @@ -33,106 +31,50 @@ export default class AiroticDeviceProvider extends DeviceProvider super(deviceManager, eventEmitter, logger.child({ name: AiroticDeviceProvider.name })); this.settings = settings; - - this.deviceManager.on( - DeviceManagerEvent.deviceDetected, - asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ) - ); } public override async init(): Promise { - // no initialization needed, Airotic devices are announced by the AiroticBleObserver this.logger.debug('Initialized AiroticDeviceProvider'); } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - this.logger.debug(`Handling detected device with id ${deviceInfo.id}`); - - if (!this.isBleDeviceInfo(deviceInfo)) { - return; - } - - try { - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - - if (!acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); - return; - } - - const transport = await promiseWithTimeout(BleUartDeviceTransport.create( - deviceInfo.peripheral, - AiroticDeviceProvider.UART_RX_CHAR_UUID, - AiroticDeviceProvider.UART_TX_CHAR_UUID - ), 5000, `Timed out while creating BLE transport for device ${deviceInfo.id}`); - - this.logger.debug(`Connected to device: ${deviceInfo.id}`); - - const protocol = new AiroticProtocol(); - - const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger, 2000); + protected override async connectBleDevice(deviceInfo: BleDeviceInfo): Promise { + const transport = await promiseWithTimeout(BleUartDeviceTransport.create( + deviceInfo.peripheral, + AiroticDeviceProvider.UART_RX_CHAR_UUID, + AiroticDeviceProvider.UART_TX_CHAR_UUID + ), 5000, `Timed out while creating BLE transport for device ${deviceInfo.id}`); - const handshakeResult = await this.doHandshake(messageResponseHandler); + this.logger.debug(`Connected to device: ${deviceInfo.id}`); - if (handshakeResult) { - const device = this.createDevice(deviceInfo, deviceInfo.peripheral, messageResponseHandler); + const protocol = new AiroticProtocol(); + const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger, 2000); - device.on(DeviceEvent.deviceDisconnected, asyncHandler( - async () => await transport.close(), - (e: unknown) => logError(this.logger, `Error in device disconnected handler for device ${device.getDeviceId}`, e)) - ); + const handshakeSucceeded = await this.doHandshake(messageResponseHandler); - this.deviceManager.addDevice(device); - - this.deviceManager.claimDetectedDevice(deviceInfo.id); - } else { - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - await transport.close(); - await this.disconnectDevice(deviceInfo.peripheral); - } - } catch (e: unknown) { - logError(this.logger, `Error while connecting to device`, e); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - await this.disconnectDevice(deviceInfo.peripheral); - } - } - - private async disconnectDevice(peripheral: Peripheral): Promise { - if (peripheral.state === 'connected') { - await promiseWithTimeout( - peripheral.disconnectAsync(), - 2000, - `Timed out while disconnecting from device ${peripheral.id}` - ); + if (!handshakeSucceeded) { + await transport.close(); + return undefined; } - } - private createDevice( - deviceInfo: BleDeviceInfo, - peripheral: Peripheral, - messageResponseHandler: MessageResponseHandler - ): AiroticDevice - { - const knownDevice = this.createKnownDevice(deviceInfo.id, deviceInfo.peripheral.advertisement.localName ?? `Airotic ${deviceInfo.id}`, AiroticDeviceProvider.providerName); + const knownDevice = this.createKnownDevice( + deviceInfo.id, + deviceInfo.peripheral.advertisement.localName ?? `Airotic ${deviceInfo.id}`, + ); const device = new AiroticDevice( knownDevice.id, knownDevice.name, AiroticDeviceProvider.providerName, - peripheral, + deviceInfo.peripheral, + transport, messageResponseHandler, new Date(), true, { - restColor: new StrDeviceAttribute('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, ''), - breathInColor: new StrDeviceAttribute('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, ''), - resetColors: new BoolDeviceAttribute('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly, false), - reboot: new BoolDeviceAttribute('reboot', 'Reboot bottle', DeviceAttributeModifier.writeOnly, false), + restColor: new StrDeviceAttribute('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, undefined), + breathInColor: new StrDeviceAttribute('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, undefined), + resetColors: new BoolDeviceAttribute('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly, undefined), + reboot: new BoolDeviceAttribute('reboot', 'Reboot bottle', DeviceAttributeModifier.writeOnly, undefined), }, {}, new EventEmitter(), @@ -157,27 +99,14 @@ export default class AiroticDeviceProvider extends DeviceProvider return false; } - - private createKnownDevice(deviceId: DeviceId, deviceName: string, provider: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(deviceId) + private createKnownDevice(deviceId: DeviceId, deviceName: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId); if (undefined !== knownDevice) { - // Return already existing device if already known (previously detected serial number) this.logger.debug(`Device is already known: ${knownDevice.id}`); return knownDevice; } - // Create a new device and return if not yet known (new serial number) - return new KnownDevice( - deviceId, - deviceName, - 'airotic', - provider - ); - } - - private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo - { - return 'peripheral' in deviceInfo; + return new KnownDevice(deviceId, deviceName, 'airotic', AiroticDeviceProvider.providerName); } } diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts new file mode 100644 index 00000000..c458e8dc --- /dev/null +++ b/src/device/provider/bleDeviceProvider.ts @@ -0,0 +1,81 @@ +import EventEmitter from 'events'; +import { Peripheral } from '@stoprocent/noble'; +import DeviceProvider from './deviceProvider.js'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; +import Logger from '../../logging/Logger.js'; +import { asyncHandler, promiseWithTimeout } from '../../util/async.js'; +import { logError } from '../../util/error.js'; +import { BleDeviceInfo } from '../transport/bleObserver.js'; +import BleDevice, { InferBleDeviceAttributes, InferBleDeviceConfig } from '../bleDevice.js'; +import { DeviceAttributes } from '../device.js'; +import { AnyDeviceConfig } from '../deviceConfig.js'; + +export default abstract class BleDeviceProvider< + D extends BleDevice, + TAttributes extends DeviceAttributes = InferBleDeviceAttributes, + TConfig extends AnyDeviceConfig = InferBleDeviceConfig +> extends DeviceProvider +{ + protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + super(deviceManager, eventEmitter, logger); + + this.deviceManager.on( + DeviceManagerEvent.deviceDetected, + asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) + ) + ); + } + + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + if (!this.isBleDeviceInfo(deviceInfo)) { + return; + } + + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + + if (!acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); + return; + } + + try { + const device = await this.connectBleDevice(deviceInfo); + + if (undefined === device) { + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + return; + } + + this.deviceManager.addDevice(device); + this.deviceManager.claimDetectedDevice(deviceInfo.id); + } catch (e: unknown) { + logError(this.logger, 'Error while connecting to BLE device', e); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + await this.disconnectPeripheral(deviceInfo.peripheral); + } + } + + private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { + return deviceInfo.type === 'ble'; + } + + private async disconnectPeripheral(peripheral: Peripheral): Promise { + if (peripheral.state === 'connected') { + try { + await promiseWithTimeout( + peripheral.disconnectAsync(), + 2000, + `Timed out while disconnecting from device ${peripheral.id}` + ); + } catch (e: unknown) { + logError(this.logger, `Error disconnecting peripheral ${peripheral.id}`, e); + } + } + } + + protected abstract connectBleDevice(deviceInfo: BleDeviceInfo): Promise; +} diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index c7f93ac7..6ce12d77 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -72,7 +72,7 @@ export default abstract class SerialDeviceProvider< private isSerialDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is SerialDeviceInfo { - return 'portInfo' in deviceInfo; + return deviceInfo.type === 'serial'; } private async connectToDevice(deviceInfo: SerialDeviceInfo): Promise { diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index 1ec14bc3..44666d02 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -16,6 +16,7 @@ export default class BleUartDeviceTransport implements DeviceTransport private onCloseSubscribers: (() => Promise)[] = []; private onReceiveSubscribers: ((data: Buffer) => void)[] = []; + private onConnectedSubscribers: (() => void)[] = []; private readonly connectHandler: (err: Error) => void; private readonly disconnectHandler: (err: Error) => void; @@ -83,6 +84,10 @@ export default class BleUartDeviceTransport implements DeviceTransport } this.isConnected = true; + + for (const callback of this.onConnectedSubscribers) { + callback(); + } } finally { this.isSubscribing = false; } @@ -109,6 +114,10 @@ export default class BleUartDeviceTransport implements DeviceTransport this.onCloseSubscribers.push(callback); } + public onConnected(callback: () => void): void { + this.onConnectedSubscribers.push(callback); + } + public isOpen(): boolean { return this.isConnected; } diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index bbf7802d..dd665478 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -6,6 +6,7 @@ import { DeviceId } from '../deviceId.js'; import { asyncHandler } from '../../util/async.js'; export type BleDeviceInfo = DeviceInfo & { + type: 'ble'; peripheral: Peripheral; }; @@ -55,6 +56,7 @@ export default class BleObserver } const deviceInfo: BleDeviceInfo = { + type: 'ble', id: DeviceId.create(peripheral.id), peripheral, }; diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 5b65f942..5d7a1935 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -7,6 +7,7 @@ import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; export type SerialDeviceInfo = DeviceInfo & { + type: 'serial'; portInfo: PortInfo; }; @@ -62,6 +63,7 @@ export default class SerialPortObserver if (!this.managedDevices.has(portInfo.serialNumber)) { const deviceInfo: SerialDeviceInfo = { + type: 'serial', id: DeviceId.create(portInfo.serialNumber), portInfo }; diff --git a/tests/unit/device/bleDevice.spec.ts b/tests/unit/device/bleDevice.spec.ts index ad9de4c6..1916bb77 100644 --- a/tests/unit/device/bleDevice.spec.ts +++ b/tests/unit/device/bleDevice.spec.ts @@ -9,13 +9,17 @@ import { NoDeviceConfig } from '../../../src/device/deviceConfig.js'; import Logger from '../../../src/logging/Logger.js'; import DeviceState from '../../../src/device/deviceState.js'; import { DeviceEvent } from '../../../src/device/device.js'; +import BleUartDeviceTransport from '../../../src/device/transport/bleDeviceTransport.js'; class TestBleDevice extends BleDevice { + public syncStateCalls = 0; + public constructor( deviceId: DeviceId, deviceName: string, provider: string, peripheral: Peripheral, + transport: BleUartDeviceTransport, connectedSince: Date, controllable: boolean, attributes: DeviceAttributes, @@ -23,7 +27,7 @@ class TestBleDevice extends BleDevice { eventEmitter: EventEmitter, logger: Logger, ) { - super(deviceId, deviceName, provider, peripheral, connectedSince, controllable, attributes, config, eventEmitter, logger); + super(deviceId, deviceName, provider, peripheral, transport, connectedSince, controllable, attributes, config, eventEmitter, logger); } public async setAttribute>( @@ -32,10 +36,15 @@ class TestBleDevice extends BleDevice { ): Promise> { throw new Error('Not implemented'); } + + protected override async syncState(): Promise { + this.syncStateCalls++; + } } describe('BleDevice', () => { let mockPeripheral: ReturnType>; + let mockTransport: ReturnType>; let mockLogger: ReturnType>; let peripheralState: PeripheralState; @@ -45,6 +54,7 @@ describe('BleDevice', () => { 'Test Device', 'test-provider', mockPeripheral, + mockTransport, new Date(), true, {}, @@ -58,6 +68,7 @@ describe('BleDevice', () => { vi.useFakeTimers(); mockPeripheral = mock(); + mockTransport = mock(); mockLogger = mock(); mockLogger.child.mockReturnValue(mockLogger); @@ -67,6 +78,7 @@ describe('BleDevice', () => { mockPeripheral.disconnectAsync.mockResolvedValue(undefined); mockPeripheral.updateRssiAsync.mockResolvedValue(-60); + mockTransport.close.mockResolvedValue(undefined); }); afterEach(() => { @@ -80,6 +92,28 @@ describe('BleDevice', () => { expect(mockPeripheral.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); }); + + it('registers an onConnected callback on the transport', () => { + createDevice(); + + expect(mockTransport.onConnected).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe('syncState', () => { + it('calls syncState when the transport fires onConnected', async () => { + let onConnectedCallback: (() => void) | undefined; + mockTransport.onConnected.mockImplementation((cb) => { onConnectedCallback = cb; }); + + const device = createDevice(); + + expect(device.syncStateCalls).toBe(0); + + onConnectedCallback?.(); + await Promise.resolve(); + + expect(device.syncStateCalls).toBe(1); + }); }); describe('RSSI update interval', () => { @@ -112,6 +146,17 @@ describe('BleDevice', () => { }); describe('close', () => { + it('closes the transport before disconnecting', async () => { + const device = createDevice(); + const callOrder: string[] = []; + mockTransport.close.mockImplementation(async () => { callOrder.push('transport'); }); + mockPeripheral.disconnectAsync.mockImplementation(async () => { callOrder.push('peripheral'); }); + + await device.close(); + + expect(callOrder).toStrictEqual(['transport', 'peripheral']); + }); + it('disconnects peripheral when state is connected', async () => { const device = createDevice(); diff --git a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts index 7dc1b300..67a7f2d7 100644 --- a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts +++ b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts @@ -10,6 +10,7 @@ import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAtt import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; import Logger from '../../../../../src/logging/Logger.js'; +import BleUartDeviceTransport from '../../../../../src/device/transport/bleDeviceTransport.js'; vi.mock('../../../../../src/util/async.js', async (importOriginal) => { const actual = await importOriginal(); @@ -18,6 +19,7 @@ vi.mock('../../../../../src/util/async.js', async (importOriginal) => { describe('AiroticDevice', () => { let mockPeripheral: ReturnType>; + let mockTransport: ReturnType>; let mockHandler: ReturnType>>; let mockLogger: ReturnType>; @@ -25,8 +27,8 @@ describe('AiroticDevice', () => { return { restColor: new StrDeviceAttribute('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, undefined), breathInColor: new StrDeviceAttribute('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, undefined), - resetColors: new BoolDeviceAttribute('resetColors', 'Reset Colors', DeviceAttributeModifier.readWrite, undefined), - reboot: new BoolDeviceAttribute('reboot', 'Reboot', DeviceAttributeModifier.readWrite, undefined), + resetColors: new BoolDeviceAttribute('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly, undefined), + reboot: new BoolDeviceAttribute('reboot', 'Reboot', DeviceAttributeModifier.writeOnly, undefined), }; } @@ -36,6 +38,7 @@ describe('AiroticDevice', () => { 'Airotic', 'airotic-provider', mockPeripheral, + mockTransport, mockHandler, new Date(), true, @@ -50,6 +53,7 @@ describe('AiroticDevice', () => { vi.useFakeTimers(); mockPeripheral = mock(); + mockTransport = mock(); mockHandler = mock>(); mockLogger = mock(); mockLogger.child.mockReturnValue(mockLogger); @@ -58,6 +62,7 @@ describe('AiroticDevice', () => { Object.defineProperty(mockPeripheral, 'state', { get: () => 'connected', configurable: true }); mockPeripheral.disconnectAsync.mockResolvedValue(undefined); + mockTransport.close.mockResolvedValue(undefined); }); afterEach(() => { @@ -83,6 +88,14 @@ describe('AiroticDevice', () => { expect(result).toStrictEqual('100,200,50'); }); + + it('persists the value so syncState can replay it', async () => { + const device = createDevice(); + + await device.setAttribute('restColor', '10,20,30'); + + expect((await device.getAttribute('restColor'))?.value).toStrictEqual('10,20,30'); + }); }); describe('setAttribute breathInColor', () => { @@ -103,6 +116,14 @@ describe('AiroticDevice', () => { expect(result).toStrictEqual('50,50,50'); }); + + it('persists the value so syncState can replay it', async () => { + const device = createDevice(); + + await device.setAttribute('breathInColor', '5,10,15'); + + expect((await device.getAttribute('breathInColor'))?.value).toStrictEqual('5,10,15'); + }); }); describe('setAttribute resetColors', () => { @@ -168,6 +189,52 @@ describe('AiroticDevice', () => { }); }); + describe('syncState', () => { + it('re-sends restColor and breathInColor when both have been set', async () => { + const device = createDevice(); + await device.setAttribute('restColor', '255,0,0'); + await device.setAttribute('breathInColor', '0,0,255'); + mockHandler.send.mockClear(); + + await Reflect.apply((device as any).syncState, device, []); + + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSetColorMessage(255, 0, 0)); + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectBreathInColorMessage()); + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSetColorMessage(0, 0, 255)); + }); + + it('skips restColor when it has not been set', async () => { + const device = createDevice(); + await device.setAttribute('breathInColor', '0,0,255'); + mockHandler.send.mockClear(); + + await Reflect.apply((device as any).syncState, device, []); + + expect(mockHandler.send).not.toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectBreathInColorMessage()); + }); + + it('skips breathInColor when it has not been set', async () => { + const device = createDevice(); + await device.setAttribute('restColor', '255,0,0'); + mockHandler.send.mockClear(); + + await Reflect.apply((device as any).syncState, device, []); + + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); + expect(mockHandler.send).not.toHaveBeenCalledWith(AiroticProtocol.createSelectBreathInColorMessage()); + }); + + it('sends nothing when no rw attributes have been set', async () => { + const device = createDevice(); + + await Reflect.apply((device as any).syncState, device, []); + + expect(mockHandler.send).not.toHaveBeenCalled(); + }); + }); + describe('color parsing', () => { it('throws when color has wrong number of components', async () => { const device = createDevice(); diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts index 64fbe2f7..cf45f5b7 100644 --- a/tests/unit/device/transport/bleObserver.spec.ts +++ b/tests/unit/device/transport/bleObserver.spec.ts @@ -155,7 +155,7 @@ describe('BleObserver', () => { expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ peripheral }), + expect.objectContaining({ type: 'ble', peripheral }), ); }); From 5b17c7e1bd7f637af2ad1be233fcf27612e7b695 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 27 Jun 2026 15:08:36 +0200 Subject: [PATCH 28/42] wip --- .env.example | 3 + .github/workflows/release.yml | 15 +- .github/workflows/test.yml | 13 +- LICENSE | 621 +++ eslint.config.ts | 1 + package-lock.json | 4565 +++++++++-------- package.json | 32 +- resources/schemas/settings.schema.json | 1 - src/app.ts | 274 + src/automation/scriptRuntime.ts | 414 +- .../automation/runScriptController.ts | 4 +- .../automation/stopScriptController.ts | 4 +- src/controller/getDeviceController.ts | 3 +- src/controller/healthController.ts | 46 +- src/controller/patchDeviceController.ts | 3 +- .../attribute/intRangeDeviceAttribute.ts | 2 +- src/device/deviceManager.ts | 31 +- src/device/protocol/airotic/airoticDevice.ts | 4 +- .../protocol/airotic/airoticDeviceProvider.ts | 4 +- .../protocol/buttplugIo/buttplugIoDevice.ts | 4 + .../buttplugIo/buttplugIoDeviceFactory.ts | 6 +- .../buttplugIoWebsocketDeviceProvider.ts | 3 +- .../estim2b/estim2bSerialDeviceProvider.ts | 6 +- .../protocol/slvCtrlPlus/slvCtrlPlusDevice.ts | 2 +- .../slvCtrlPlusSerialDeviceProvider.ts | 9 +- .../randomGeneratorVirtualDeviceLogic.ts | 12 +- .../protocol/virtual/virtualDeviceProvider.ts | 16 +- .../virtual/virtualDeviceProviderFactory.ts | 15 +- .../protocol/zc95/zc95SerialDeviceProvider.ts | 15 +- src/device/provider/deviceProvider.ts | 4 + src/device/provider/deviceProviderLoader.ts | 42 - src/device/provider/deviceProviderManager.ts | 64 + src/device/provider/serialDeviceProvider.ts | 28 +- src/device/serializedTypes.ts | 102 + src/device/transport/serialPortObserver.ts | 57 +- src/device/webSocketEvent.ts | 1 + src/env.ts | 32 + src/factory/serialPortFactory.ts | 4 +- src/health/healthMetricsCollector.ts | 118 + src/health/serializedTypes.ts | 34 + src/index.ts | 169 +- src/repository/automationScriptRepository.ts | 20 +- src/repository/connectedDeviceRepository.ts | 3 +- src/serial/synchronousSerialPort.ts | 6 +- src/serialization/classToPlainSerializer.ts | 4 +- src/serviceMap.ts | 12 +- .../automationServiceProvider.ts | 12 +- .../controllerServiceProvider.ts | 2 +- src/serviceProvider/deviceServiceProvider.ts | 10 +- src/serviceProvider/healthServiceProvider.ts | 13 + .../repositoryServiceProvider.ts | 11 +- src/serviceProvider/serverServiceProvider.ts | 24 - .../settingsServiceProvider.ts | 16 +- src/settings/serializedTypes.ts | 21 + src/socket/deviceUpdateHandler.ts | 3 +- src/socket/types.ts | 22 + src/util/async.ts | 12 +- src/util/error.ts | 10 +- src/util/expressUtils.ts | 4 +- src/util/objects.ts | 4 + tests/integration/api.spec.ts | 284 + tests/integration/automationScripts.spec.ts | 232 + tests/integration/deviceEvents.spec.ts | 145 + .../devices/buttplugIoDevice.spec.ts | 195 + .../integration/devices/estim2bDevice.spec.ts | 224 + .../devices/slvCtrlSerialDevice.spec.ts | 294 ++ tests/integration/devices/zc95Device.spec.ts | 327 ++ tests/integration/helpers/appHelper.ts | 246 + .../helpers/buttplugIoServerSimulator.ts | 266 + .../helpers/estim2bDeviceSimulator.ts | 166 + .../helpers/mockSerialPortFactory.ts | 68 + .../helpers/slvCtrlPlusDeviceSimulator.ts | 190 + .../helpers/zc95DeviceSimulator.ts | 179 + tests/tsconfig.json | 11 + tests/unit/automation/scriptRuntime.spec.ts | 455 ++ .../unit/controller/healthController.spec.ts | 37 + .../buttplugIo/buttplugIoDevice.spec.ts | 4 +- .../display/displayVirtualDevice.spec.ts | 9 +- tests/unit/device/testDevice.ts | 8 +- tests/unit/device/testDeviceProvider.ts | 7 +- .../transport/serialPortObserver.spec.ts | 20 +- tsconfig.json | 3 +- vitest.config.integration.ts | 11 + vitest.config.ts | 1 + 84 files changed, 7738 insertions(+), 2636 deletions(-) create mode 100644 LICENSE create mode 100644 src/app.ts delete mode 100644 src/device/provider/deviceProviderLoader.ts create mode 100644 src/device/provider/deviceProviderManager.ts create mode 100644 src/device/serializedTypes.ts create mode 100644 src/env.ts create mode 100644 src/health/healthMetricsCollector.ts create mode 100644 src/health/serializedTypes.ts create mode 100644 src/serviceProvider/healthServiceProvider.ts delete mode 100644 src/serviceProvider/serverServiceProvider.ts create mode 100644 src/settings/serializedTypes.ts create mode 100644 tests/integration/api.spec.ts create mode 100644 tests/integration/automationScripts.spec.ts create mode 100644 tests/integration/deviceEvents.spec.ts create mode 100644 tests/integration/devices/buttplugIoDevice.spec.ts create mode 100644 tests/integration/devices/estim2bDevice.spec.ts create mode 100644 tests/integration/devices/slvCtrlSerialDevice.spec.ts create mode 100644 tests/integration/devices/zc95Device.spec.ts create mode 100644 tests/integration/helpers/appHelper.ts create mode 100644 tests/integration/helpers/buttplugIoServerSimulator.ts create mode 100644 tests/integration/helpers/estim2bDeviceSimulator.ts create mode 100644 tests/integration/helpers/mockSerialPortFactory.ts create mode 100644 tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts create mode 100644 tests/integration/helpers/zc95DeviceSimulator.ts create mode 100644 tests/tsconfig.json create mode 100644 tests/unit/automation/scriptRuntime.spec.ts create mode 100644 tests/unit/controller/healthController.spec.ts create mode 100644 vitest.config.integration.ts diff --git a/.env.example b/.env.example index a66a947a..e60223a4 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ PORT=1337 LOG_LEVEL=debug ALLOWED_ORIGINS= APP_VERSION=dev +# SSL_KEY_FILE=/path/to/server.key +# SSL_CERT_FILE=/path/to/server.crt +# HTTPS_PORT=1338 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70d6b091..0165f476 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,11 +15,11 @@ jobs: - name: Setup Virtual ALSA Device uses: ./.github/actions/setup-virtual-alsa - name: Set node version - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: '20.x' + node-version: '24.x' - name: Install modules - run: npm install + run: npm ci - name: Tag id: set_tag uses: K-Phoen/semver-release-action@v1.3.2 @@ -36,11 +36,14 @@ jobs: - name: Linting run: | npm run lint - - name: Coverage + - name: Unit tests with coverage run: | - npm run coverage + npm run test:coverage + - name: Integration tests + run: | + npm run test:integration - name: Compress build - uses: a7ul/tar-action@v1.1.3 + uses: a7ul/tar-action@v1.2.0 id: compress with: command: c diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 073176e0..b7f3196a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20.x, 22.x, 24.x] + node-version: [24.x] combo: - runs-on: ubuntu-24.04-arm platform: linux-arm64 @@ -35,20 +35,23 @@ jobs: - name: Setup Virtual ALSA Device uses: ./.github/actions/setup-virtual-alsa - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} - name: Install modules - run: npm install + run: npm ci - name: Linting run: | npm run lint - name: Typecheck run: | npm run typecheck - - name: Coverage + - name: Unit tests with coverage run: | - npm run coverage + npm run test:coverage + - name: Integration tests + run: | + npm run test:integration # Summary job that all matrix jobs must pass # Used as the required status check in branch protection diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..810fce6e --- /dev/null +++ b/LICENSE @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/eslint.config.ts b/eslint.config.ts index 191e4426..2e5c67d3 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,6 +12,7 @@ export default [ 'coverage', 'tests', 'vitest.config.ts', + 'vitest.config.integration.ts', 'eslint.config.ts', ], }, diff --git a/package-lock.json b/package-lock.json index b9970fee..bc208433 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,17 @@ "dependencies": { "@sinclair/typebox": "^0.34.47", "@stoprocent/noble": "^2.3.17", - "@timesplinter/pimple": "^2.0.0", + "@timesplinter/pimple": "^2.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "buttplug": "^3.2.1", + "buttplug": "^3.2.2", "class-transformer": "^0.5.1", "cors": "^2.8.5", "dotenv": "^17.2.0", "express": "^5.2.1", - "glob": "^8.0.1", + "isolated-vm": "^6.0.2", "modern-errors": "^7.1.4", - "node-os-utils": "^1.3.7", + "node-os-utils": "^2.0.3", "on-change": "^4.0.1", "pino": "^10.3.1", "read-last-lines": "^1.8.0", @@ -28,10 +28,11 @@ "serialport": "^13.0.0", "socket.io": "^4.8.3", "speaker": "https://github.com/SlvCtrlPlus/node-speaker/releases/download/v0.1.0/speaker-v0.1.0.tgz", + "sucrase": "^3.35.1", "type-detect": "^4.1.0", "unique-names-generator": "^4.7.1", - "uuid": "^8.3.2", - "vm2": "^3.10.0" + "usb": "^3.0.0", + "uuid": "^14.0.0" }, "devDependencies": { "@babel/core": "^7.22.8", @@ -40,12 +41,11 @@ "@babel/preset-env": "^7.14.4", "@babel/preset-typescript": "^7.22.5", "@eslint/js": "^10.0.1", + "@serialport/binding-mock": "^10.2.2", "@types/cors": "^2.8.10", "@types/express": "^5.0.6", - "@types/glob": "^7.2.0", "@types/node": "^24.1.0", - "@types/node-os-utils": "^1.3.1", - "@types/uuid": "^8.3.0", + "@types/supertest": "^7.2.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", "eslint": "^10.1.0", @@ -56,23 +56,26 @@ "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^13.1.3", + "socket.io-client": "^4.8.3", + "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", "vitest": "^4.0.16", - "vitest-mock-extended": "^3.1.0" + "vitest-mock-extended": "^3.1.0", + "ws": "^8.21.0" }, "engines": { - "node": ">=18.18.0 <23.0.0" + "node": ">=24.0.0 <25.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -81,9 +84,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -91,21 +94,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -122,14 +125,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -139,27 +142,27 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -169,18 +172,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "engines": { @@ -191,13 +194,13 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, @@ -209,26 +212,26 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", - "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", "lodash.debounce": "^4.0.8", - "resolve": "^1.22.10" + "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -236,43 +239,43 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -282,22 +285,22 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -305,15 +308,15 @@ } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -323,15 +326,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -341,23 +344,23 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -365,9 +368,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -375,9 +378,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -385,44 +388,44 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/node": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/node/-/node-7.28.6.tgz", - "integrity": "sha512-X3i8Zq/3Au5of74DzuYfgKS2oKRrYGcd5O5QlrB7AqspYKWy0XRTG69HDYnower3HRH/lL38IYAZVPh3OQMXVg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/node/-/node-7.29.7.tgz", + "integrity": "sha512-nfdPXz8/mD3/t+1nE1DKwGR14Ccjt5xeF7u3g7sqWnLi4yR6n+9Z0kThIROF8SRM07ZKpEtiWSKpWKxsMiJeew==", "dev": true, "license": "MIT", "dependencies": { - "@babel/register": "^7.28.6", + "@babel/register": "^7.29.7", "commander": "^6.2.0", - "core-js": "^3.30.2", + "core-js": "^3.48.0", "node-environment-flags": "^1.0.5", "regenerator-runtime": "^0.14.0", "v8flags": "^3.1.1" @@ -438,13 +441,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -454,14 +457,14 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -471,13 +474,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -487,13 +490,30 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -503,15 +523,15 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -521,14 +541,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -538,15 +558,15 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.6.tgz", - "integrity": "sha512-RVdFPPyY9fCRAX68haPmOk2iyKW8PKJFthmm8NeSI3paNxKWGZIn99+VbIf0FrtCpFnPgnpF/L48tadi617ULg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-decorators": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -569,13 +589,13 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", - "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -585,13 +605,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -601,13 +621,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -617,13 +637,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -633,13 +653,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -666,13 +686,13 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -682,15 +702,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz", - "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -700,15 +720,15 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -718,13 +738,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -734,13 +754,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -750,14 +770,14 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -767,14 +787,14 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -784,18 +804,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -805,14 +825,14 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -822,14 +842,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -839,14 +859,14 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -856,13 +876,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -872,14 +892,14 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz", - "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -889,13 +909,13 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -905,14 +925,14 @@ } }, "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -922,13 +942,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -938,13 +958,13 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -954,14 +974,14 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -971,15 +991,15 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -989,13 +1009,13 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1005,13 +1025,13 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1021,13 +1041,13 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1037,13 +1057,13 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1053,14 +1073,14 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1070,14 +1090,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1087,16 +1107,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", - "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.5" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1106,14 +1126,14 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1123,14 +1143,14 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1140,13 +1160,13 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1156,13 +1176,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1172,13 +1192,13 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1188,17 +1208,17 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1208,14 +1228,14 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1225,13 +1245,13 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1241,14 +1261,14 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1258,13 +1278,13 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1274,14 +1294,14 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1291,15 +1311,15 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1309,13 +1329,13 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1325,13 +1345,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz", - "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1341,14 +1361,14 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1358,13 +1378,13 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1374,13 +1394,13 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1390,14 +1410,14 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1407,13 +1427,13 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1423,13 +1443,13 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1439,13 +1459,13 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1455,17 +1475,17 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1475,13 +1495,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1491,14 +1511,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1508,14 +1528,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1525,14 +1545,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1542,81 +1562,82 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz", - "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.6", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.28.5", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.6", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "core-js-compat": "^3.43.0", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "engines": { @@ -1642,17 +1663,17 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1662,9 +1683,9 @@ } }, "node_modules/@babel/register": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.28.6.tgz", - "integrity": "sha512-pgcbbEl/dWQYb6L6Yew6F94rdwygfuv+vJ/tXfwIOYAfPB6TNWpXUMEtEq3YuTeHRdvMIhvz13bkT9CNaS+wqA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz", + "integrity": "sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA==", "dev": true, "license": "MIT", "dependencies": { @@ -1682,33 +1703,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1716,14 +1737,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1739,18 +1760,52 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.84.0.tgz", - "integrity": "sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", + "integrity": "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.54.0", - "comment-parser": "1.4.5", + "@typescript-eslint/types": "^8.58.0", + "comment-parser": "1.4.6", "esquery": "^1.7.0", - "jsdoc-type-pratt-parser": "~7.1.1" + "jsdoc-type-pratt-parser": "~7.2.0" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -1766,715 +1821,468 @@ "node": ">=10" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "node_modules/@node-usb/usb-darwin-arm64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-darwin-arm64/-/usb-darwin-arm64-3.0.0.tgz", + "integrity": "sha512-wBnK74ToiFpH8QGsOSkxjx2BllHIaeW57AUIrAEd1ZWdulbR7l63e07efUSi+iQDf7Qd8HLkD1DnA4w92lagcQ==", "cpu": [ - "x64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "node_modules/@node-usb/usb-darwin-x64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-darwin-x64/-/usb-darwin-x64-3.0.0.tgz", + "integrity": "sha512-myCWHDMSO7Xo6bSmCprTAuIj/KBD+z2YpKVnDCv65ZB5vF83gqz+7Qlnbqgr92IarAjPLldkyM5dgpHxGF9l5A==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "netbsd" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "node_modules/@node-usb/usb-linux-arm-gnueabihf": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm-gnueabihf/-/usb-linux-arm-gnueabihf-3.0.0.tgz", + "integrity": "sha512-EOQOP7QQf92Nr37jaBlL7W4pkB+9YCVxI/l1PZ19KZr/kqBIArdVw4DbkADmJmLwYl4yIeDsJLuQ2O9xLbYcvA==", "cpu": [ - "x64" + "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "node_modules/@node-usb/usb-linux-arm64-gnu": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm64-gnu/-/usb-linux-arm64-gnu-3.0.0.tgz", + "integrity": "sha512-H6TLWmgeK0QSDUUCR1KRGQiNWFnSsv9Ec2Bnet8LQJrRtdb0np92oObgzn7X7nz4rpcuahfxH8H2xnLQatZnWA==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "node_modules/@node-usb/usb-linux-arm64-musl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm64-musl/-/usb-linux-arm64-musl-3.0.0.tgz", + "integrity": "sha512-d2ROSTlGh0axwSVw3cGXlUEzCW8vJsJOI6T/82xsB/mO+Wy8IC6eRaVzIyEx+hrBiW/TNVI8d9QrIHWG8I9/5A==", "cpu": [ - "x64" + "arm64" + ], + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "node_modules/@node-usb/usb-linux-x64-gnu": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-x64-gnu/-/usb-linux-x64-gnu-3.0.0.tgz", + "integrity": "sha512-r3I6kGKiIm6J5qpdte4aHvMXXizLPonwq5f2ZGZGBY9VJUrUVjV5g1wW5spPZoq5E+R1V+metup1zMWd1Topew==", "cpu": [ - "arm64" + "x64" + ], + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "node_modules/@node-usb/usb-linux-x64-musl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-x64-musl/-/usb-linux-x64-musl-3.0.0.tgz", + "integrity": "sha512-Qtzms0RQ4XXsuVwiNSDxXsuubfbzVELB4WH4MyP1ABigibA+yslMLHJLZbZyZA1NpOP32Pikmz6C7o9Ho6Z2LQ==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ - "sunos" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "node_modules/@node-usb/usb-win32-arm64-msvc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-arm64-msvc/-/usb-win32-arm64-msvc-3.0.0.tgz", + "integrity": "sha512-w4WfwMY07MPHF+393sCUTLl8cZMqyUlEvsxVZjDefXe3LsC/EKhxLWYqVOFszlhYGDpe5YLs2VjFLi5miq0ZjQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "node_modules/@node-usb/usb-win32-ia32-msvc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-ia32-msvc/-/usb-win32-ia32-msvc-3.0.0.tgz", + "integrity": "sha512-2531uD4hB1ccesjyapYB//VDEARnhx8u1oljBDy5O7o7qZh74KjNtlYXBZBAmCjtZfqIEH+L+YaBFVg39VdTUQ==", "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "node_modules/@node-usb/usb-win32-x64-msvc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-x64-msvc/-/usb-win32-x64-msvc-3.0.0.tgz", + "integrity": "sha512-8wbRBW1WKzmOWyTJs9n7/rYytYjL+/hvxaW6rlYuHcj0gLDupDJb9l64FhPI8ZZtF19+LVatQeoNtQLoKVRAuQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.3", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@noble/hashes": "^1.1.5" } }, "node_modules/@pinojs/redact": { @@ -2483,24 +2291,10 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -2509,12 +2303,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -2523,12 +2320,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -2537,26 +2337,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -2565,26 +2354,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -2593,180 +2371,135 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", - "cpu": [ - "loong64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", - "cpu": [ - "riscv64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -2775,40 +2508,51 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -2817,21 +2561,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@serialport/binding-mock": { "version": "10.2.2", @@ -2910,6 +2650,15 @@ } } }, + "node_modules/@serialport/bindings-cpp/node_modules/node-addon-api": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/@serialport/bindings-interface": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", @@ -3073,9 +2822,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.47", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", - "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "license": "MIT" }, "node_modules/@sindresorhus/base62": { @@ -3389,16 +3138,6 @@ "license": "MIT", "optional": true }, - "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/node-addon-api": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", - "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/serialport": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/serialport/-/serialport-12.0.0.tgz", @@ -3446,10 +3185,26 @@ } } }, + "node_modules/@stoprocent/bluetooth-hci-socket/node_modules/usb": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/usb/-/usb-2.18.0.tgz", + "integrity": "sha512-klAJPUTaSxRvTgrIh7om6UTrgRxdzioD+rJc/MaoiN8+OGdqRzai39tR00asnoCesPNikItWB9zBZoo0pJsWaA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/w3c-web-usb": "^1.0.6", + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.5.0" + }, + "engines": { + "node": ">=12.22.0 <13.0 || >=14.17.0" + } + }, "node_modules/@stoprocent/noble": { - "version": "2.3.17", - "resolved": "https://registry.npmjs.org/@stoprocent/noble/-/noble-2.3.17.tgz", - "integrity": "sha512-m1BP+B+8sYCzsnJDBeoZYQGDdGanXXXIPx3hVKBbR4tWcJFQ0RK8GM1PcavEuYW4+xkmf75MvodM/b9vT25F7A==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@stoprocent/noble/-/noble-2.5.5.tgz", + "integrity": "sha512-tHtc8nEvf+J11MedleT0YBhxBrWTqJaoXQJ9H983Gnrus1cHHxs2YtCHMk/3mFuoUAIvW0o1JcH8cHBieqkUYQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3463,14 +3218,33 @@ }, "optionalDependencies": { "@stoprocent/bluetooth-hci-socket": "^2.2.6" + }, + "peerDependencies": { + "dbus-next": "^0.10.0" + }, + "peerDependenciesMeta": { + "dbus-next": { + "optional": true + } } }, "node_modules/@timesplinter/pimple": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@timesplinter/pimple/-/pimple-2.0.0.tgz", - "integrity": "sha512-H/6VXYWw7Gxx2KVc1B+v2ERZDoyLKKcEuiB0y5N80lE6Z5YmmHWdMtBQSj9WyETK2B5d2+kfQZBTtlv/KSFTsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@timesplinter/pimple/-/pimple-2.1.1.tgz", + "integrity": "sha512-Sy0nqk5480ZukdvEfx9mCmKMJ+jCnjiXePO7Flf+4RK5W5bs1qyGdExQJZ6tg6SoCKQekRvpaW6GNGl/Zf3WeA==", "license": "LGPL-3.0-or-later" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3503,6 +3277,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -3527,9 +3308,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -3558,17 +3339,6 @@ "@types/send": "*" } }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -3583,33 +3353,26 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.7.tgz", - "integrity": "sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, - "node_modules/@types/node-os-utils": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@types/node-os-utils/-/node-os-utils-1.3.4.tgz", - "integrity": "sha512-BCUYrbdoO4FUbx6MB9atLNFnkxdliFaxdiTJMIPPiecXIApc5zf4NIqV5G1jWv/ReZvtYyHLs40RkBjHX+vykA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -3641,45 +3404,60 @@ "@types/node": "*" } }, - "node_modules/@types/uuid": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", - "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } }, - "node_modules/@types/w3c-web-usb": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.13.tgz", - "integrity": "sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==", + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", - "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/type-utils": "8.57.1", - "@typescript-eslint/utils": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3689,9 +3467,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.1", + "@typescript-eslint/parser": "^8.62.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -3705,16 +3483,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", - "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3" }, "engines": { @@ -3726,18 +3504,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", - "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.1", - "@typescript-eslint/types": "^8.57.1", + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "engines": { @@ -3748,18 +3526,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", - "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1" + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3770,9 +3548,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", - "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", "dev": true, "license": "MIT", "engines": { @@ -3783,21 +3561,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", - "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3808,13 +3586,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", - "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "dev": true, "license": "MIT", "engines": { @@ -3826,76 +3604,37 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", - "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.1", - "@typescript-eslint/tsconfig-utils": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3", "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -3906,16 +3645,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", - "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1" + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3926,17 +3665,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", - "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3948,29 +3687,29 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", - "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.17", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.17", - "vitest": "4.0.17" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3979,31 +3718,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4012,7 +3751,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -4024,26 +3763,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -4051,13 +3790,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4066,9 +3806,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -4076,14 +3816,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4109,9 +3850,10 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4130,22 +3872,10 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4282,6 +4012,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4293,21 +4030,21 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", - "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "js-tokens": "^10.0.0" } }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -4328,6 +4065,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -4354,14 +4098,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", - "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { @@ -4369,37 +4113,41 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64id": { "version": "2.0.0", @@ -4411,13 +4159,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -4434,21 +4185,34 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -4458,14 +4222,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -4481,9 +4247,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -4501,11 +4267,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4571,14 +4337,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -4618,9 +4384,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -4763,6 +4529,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", @@ -4774,9 +4553,9 @@ } }, "node_modules/comment-parser": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", - "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", "dev": true, "license": "MIT", "engines": { @@ -4790,17 +4569,20 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -4844,10 +4626,17 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", - "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4857,13 +4646,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", - "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.28.0" + "browserslist": "^4.28.1" }, "funding": { "type": "opencollective", @@ -4871,9 +4660,9 @@ } }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -4881,6 +4670,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -5020,6 +4813,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5029,10 +4832,31 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5062,9 +4886,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", "dev": true, "license": "ISC" }, @@ -5088,25 +4912,40 @@ } }, "node_modules/engine.io": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", - "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -5159,27 +4998,6 @@ "node": ">= 0.6" } }, - "node_modules/engine.io/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/error-class-utils": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/error-class-utils/-/error-class-utils-4.0.1.tgz", @@ -5202,9 +5020,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -5270,6 +5088,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-array-method-boxes-properly": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", @@ -5296,16 +5133,16 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5331,15 +5168,18 @@ } }, "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -5348,48 +5188,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5420,18 +5218,21 @@ } }, "node_modules/eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", - "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.3", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -5492,19 +5293,19 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "62.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.8.0.tgz", - "integrity": "sha512-hu3r9/6JBmPG6wTcqtYzgZAnjEG2eqRUATfkFscokESg1VDxZM21ZaMire0KjeMwfj+SXvgB4Rvh5LBuesj92w==", + "version": "62.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", + "integrity": "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.84.0", + "@es-joy/jsdoccomment": "~0.86.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.5", + "comment-parser": "1.4.6", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", - "espree": "^11.1.0", + "espree": "^11.2.0", "esquery": "^1.7.0", "html-entities": "^2.6.0", "object-deep-merge": "^2.0.0", @@ -5521,9 +5322,9 @@ } }, "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5583,9 +5384,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5599,29 +5400,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5629,22 +5407,6 @@ "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -5729,15 +5491,15 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5788,9 +5550,9 @@ } }, "node_modules/fast-copy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", - "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", "dev": true, "license": "MIT" }, @@ -5822,9 +5584,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -5951,9 +5713,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5964,13 +5726,71 @@ "dev": true, "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" }, "engines": { - "node": ">= 0.4" + "node": ">=14.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://ko-fi.com/tunnckoCore/commissions" } }, "node_modules/forwarded": { @@ -6005,12 +5825,6 @@ "node": ">=12" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6036,18 +5850,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -6141,26 +5958,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6174,31 +5971,10 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globals": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz", - "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -6322,9 +6098,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6440,17 +6216,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6579,13 +6344,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -6644,6 +6409,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-error-instance": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-error-instance/-/is-error-instance-3.0.1.tgz", @@ -6974,6 +6755,19 @@ "node": ">=0.10.0" } }, + "node_modules/isolated-vm": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.1.2.tgz", + "integrity": "sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -7016,9 +6810,9 @@ } }, "node_modules/istanbul-lib-report/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -7043,9 +6837,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -7070,9 +6864,9 @@ "license": "MIT" }, "node_modules/jsdoc-type-pratt-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.1.tgz", - "integrity": "sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", + "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", "dev": true, "license": "MIT", "engines": { @@ -7145,9 +6939,9 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -7208,6 +7002,285 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7252,14 +7325,14 @@ } }, "node_modules/magicast": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, @@ -7332,6 +7405,16 @@ "node": ">=18.18.0" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7345,6 +7428,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -7371,16 +7467,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -7431,9 +7530,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -7466,9 +7565,9 @@ } }, "node_modules/node-addon-api": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", - "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -7507,29 +7606,35 @@ } }, "node_modules/node-os-utils": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/node-os-utils/-/node-os-utils-1.3.7.tgz", - "integrity": "sha512-fvnX9tZbR7WfCG5BAy3yO/nCLyjVWD6MghEq0z5FDfN+ZXpLWNITBdbifxQkQ25ebr16G0N7eRWJisOcMEHG3Q==", - "license": "MIT" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/node-os-utils/-/node-os-utils-2.0.3.tgz", + "integrity": "sha512-NqItOJn+5355jYz/fYfs4PrMNTbKNG01ILws0rZhEZ3by3a0Kg9hGmzTqVOZnXsh9uww+AHUc4d6A1jEHR6u8g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nodemon": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", - "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", @@ -7559,9 +7664,9 @@ } }, "node_modules/nodemon/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -7617,9 +7722,9 @@ } }, "node_modules/object-deep-merge": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", - "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.1.tgz", + "integrity": "sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==", "dev": true, "license": "MIT" }, @@ -7688,15 +7793,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/on-change": { "version": "4.0.2", @@ -7906,9 +8014,9 @@ } }, "node_modules/patch-package/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7944,9 +8052,9 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -7968,9 +8076,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -8046,16 +8154,15 @@ } }, "node_modules/pino-std-serializers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", - "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8151,9 +8258,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -8171,7 +8278,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8226,9 +8333,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, "license": "MIT", "dependencies": { @@ -8247,12 +8354,13 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -8268,12 +8376,16 @@ "license": "MIT" }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { @@ -8437,9 +8549,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -8472,12 +8584,13 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -8492,49 +8605,38 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, "node_modules/router": { @@ -8554,15 +8656,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -8896,14 +8998,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -8915,13 +9017,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -8988,9 +9090,9 @@ } }, "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -9028,40 +9130,35 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.18.3" + "ws": "~8.21.0" } }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "dev": true, "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, "engines": { "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } } }, "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -9115,9 +9212,9 @@ } }, "node_modules/sonic-boom": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", - "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" @@ -9173,9 +9270,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -9220,9 +9317,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -9241,19 +9338,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -9263,16 +9361,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -9312,6 +9410,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9359,17 +9524,23 @@ } }, "node_modules/thread-stream": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", - "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", "license": "MIT", "dependencies": { - "real-require": "^0.2.0" + "real-require": "^1.0.0" }, "engines": { "node": ">=20" } }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9378,9 +9549,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -9388,14 +9559,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -9408,7 +9578,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -9423,10 +9592,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -9436,9 +9604,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -9446,9 +9614,9 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "license": "MIT", "engines": { "node": ">=14.14" @@ -9503,9 +9671,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -9516,9 +9684,9 @@ } }, "node_modules/ts-essentials": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", - "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.2.1.tgz", + "integrity": "sha512-+Id1fRkuir+CsgK2x04/icS2b4V1hQmq7ObzIrDjhN0ozfRYivnP7aaKMVJfLApQm0trjR39A6NIMVchiB9Erw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -9530,6 +9698,20 @@ } } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9553,17 +9735,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typed-array-buffer": { @@ -9624,18 +9823,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -9659,16 +9858,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", - "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.1", - "@typescript-eslint/parser": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/utils": "8.57.1" + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9679,7 +9878,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/unbox-primitive": { @@ -9709,9 +9908,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -9827,28 +10026,40 @@ } }, "node_modules/usb": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/usb/-/usb-2.17.0.tgz", - "integrity": "sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==", - "hasInstallScript": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/usb/-/usb-3.0.0.tgz", + "integrity": "sha512-oKHicfd16YtVrh+icdjjTE+CNpAohAjbt8ecanLhA0F9mVjXnGsu1UoV9vZg9sWQ51Lyc5XVI/jFdqXw5IjNFQ==", "license": "MIT", - "optional": true, "dependencies": { - "@types/w3c-web-usb": "^1.0.6", - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.5.0" + "@types/w3c-web-usb": "^1.0.14" }, "engines": { - "node": ">=12.22.0 <13.0 || >=14.17.0" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + }, + "optionalDependencies": { + "@node-usb/usb-darwin-arm64": "3.0.0", + "@node-usb/usb-darwin-x64": "3.0.0", + "@node-usb/usb-linux-arm-gnueabihf": "3.0.0", + "@node-usb/usb-linux-arm64-gnu": "3.0.0", + "@node-usb/usb-linux-arm64-musl": "3.0.0", + "@node-usb/usb-linux-x64-gnu": "3.0.0", + "@node-usb/usb-linux-x64-musl": "3.0.0", + "@node-usb/usb-win32-arm64-msvc": "3.0.0", + "@node-usb/usb-win32-ia32-msvc": "3.0.0", + "@node-usb/usb-win32-x64-msvc": "3.0.0" } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8flags": { @@ -9874,18 +10085,17 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -9901,9 +10111,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -9916,13 +10127,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -9948,28 +10162,10 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -9980,31 +10176,31 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -10020,12 +10216,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -10046,6 +10245,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -10054,27 +10259,30 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, "node_modules/vitest-mock-extended": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-3.1.0.tgz", - "integrity": "sha512-vCM0VkuocOUBwwqwV7JB7YStw07pqeKvEIrZnR8l3PtwYi6rAAJAyJACeC1UYNfbQWi85nz7EdiXWBFI5hll2g==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-3.1.1.tgz", + "integrity": "sha512-IQBKkEYB5BW4eJ+8JHZRzrLkKIJu5zkLJsU6u8vB+skSXbES+Di86nrUIbXwEQivDXcR4APMZFSvt398+iqKYA==", "dev": true, "license": "MIT", "dependencies": { "ts-essentials": ">=10.0.0" }, "peerDependencies": { - "typescript": "3.x || 4.x || 5.x", + "typescript": "3.x || 4.x || 5.x || 6.x", "vitest": ">=3.0.0" } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -10084,22 +10292,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vm2": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.10.1.tgz", - "integrity": "sha512-5VKPEiEzLPiIKxfHoGoafr1yL5PKeALbbwwYhiwyEgWmslnao7e1UxKGL93ekGGt45ETs+zFB0tTYLADzCWH8g==", - "license": "MIT", - "dependencies": { - "acorn": "^8.14.1", - "acorn-walk": "^8.3.4" - }, - "bin": { - "vm2": "bin/vm2" - }, - "engines": { - "node": ">=6.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10183,14 +10375,14 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -10251,9 +10443,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -10271,6 +10463,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10279,9 +10480,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 94dc3b3b..2062e05b 100644 --- a/package.json +++ b/package.json @@ -6,17 +6,17 @@ "dependencies": { "@sinclair/typebox": "^0.34.47", "@stoprocent/noble": "^2.3.17", - "@timesplinter/pimple": "^2.0.0", + "@timesplinter/pimple": "^2.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "buttplug": "^3.2.1", + "buttplug": "^3.2.2", "class-transformer": "^0.5.1", "cors": "^2.8.5", "dotenv": "^17.2.0", "express": "^5.2.1", - "glob": "^8.0.1", + "isolated-vm": "^6.0.2", "modern-errors": "^7.1.4", - "node-os-utils": "^1.3.7", + "node-os-utils": "^2.0.3", "on-change": "^4.0.1", "pino": "^10.3.1", "read-last-lines": "^1.8.0", @@ -26,10 +26,11 @@ "serialport": "^13.0.0", "socket.io": "^4.8.3", "speaker": "https://github.com/SlvCtrlPlus/node-speaker/releases/download/v0.1.0/speaker-v0.1.0.tgz", + "sucrase": "^3.35.1", "type-detect": "^4.1.0", "unique-names-generator": "^4.7.1", - "uuid": "^8.3.2", - "vm2": "^3.10.0" + "usb": "^3.0.0", + "uuid": "^14.0.0" }, "devDependencies": { "@babel/core": "^7.22.8", @@ -37,12 +38,11 @@ "@babel/plugin-proposal-decorators": "^7.22.7", "@babel/preset-env": "^7.14.4", "@babel/preset-typescript": "^7.22.5", + "@serialport/binding-mock": "^10.2.2", "@types/cors": "^2.8.10", "@types/express": "^5.0.6", - "@types/glob": "^7.2.0", "@types/node": "^24.1.0", - "@types/node-os-utils": "^1.3.1", - "@types/uuid": "^8.3.0", + "@types/supertest": "^7.2.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", "eslint": "^10.1.0", @@ -54,20 +54,24 @@ "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^13.1.3", + "socket.io-client": "^4.8.3", + "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", "vitest": "^4.0.16", - "vitest-mock-extended": "^3.1.0" + "vitest-mock-extended": "^3.1.0", + "ws": "^8.21.0" }, "engines": { - "node": ">=18.18.0 <23.0.0" + "node": ">=24.0.0 <25.0.0" }, "scripts": { "compile": "tsc && node --enable-source-maps --trace-warnings dist/index.js | pino-pretty", "dev": "nodemon", - "test": "vitest run", - "coverage": "vitest run --coverage", - "typecheck": "tsc --noEmit", + "test:unit": "vitest run --config vitest.config.ts", + "test:integration": "vitest run --config vitest.config.integration.ts", + "test:coverage": "vitest run --config vitest.config.ts --coverage", + "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json", "lint": "eslint -c eslint.config.ts .", "build": "tsc" } diff --git a/resources/schemas/settings.schema.json b/resources/schemas/settings.schema.json index 35cdd316..f0a743d2 100644 --- a/resources/schemas/settings.schema.json +++ b/resources/schemas/settings.schema.json @@ -38,4 +38,3 @@ "required": ["knownDevices", "deviceSources"], "additionalProperties": false } - diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..bf58e7ac --- /dev/null +++ b/src/app.ts @@ -0,0 +1,274 @@ +import cors, { CorsOptions } from 'cors'; +import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; +import express from 'express'; +import { Container, Pimple } from '@timesplinter/pimple'; +import ControllerServiceProvider from './serviceProvider/controllerServiceProvider.js'; +import RepositoryServiceProvider from './serviceProvider/repositoryServiceProvider.js'; +import SerializationServiceProvider from './serviceProvider/serializationServiceProvider.js'; +import FactoryServiceProvider from './serviceProvider/factoryServiceProvider.js'; +import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; +import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; +import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; +import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; +import { ClientToServerEvents, ServerToClientEvents, WebsocketServer } from './socket/types.js'; +import { SerializedDevice } from './device/serializedTypes.js'; +import { SerializedSettings } from './settings/serializedTypes.js'; +import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; +import Device from './device/device.js'; +import WebSocketEvent from './device/webSocketEvent.js'; +import AutomationEventType from './automation/automationEventType.js'; +import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; +import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; +import ServiceMap from './serviceMap.js'; +import SettingsEventType from './settings/settingsEventType.js'; +import type Settings from './settings/settings.js'; +import { executeController } from './util/expressUtils.js'; +import { DeviceManagerEvent } from './device/deviceManager.js'; +import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; +import { logError } from './util/error.js'; +import { HealthMetricsCollectorEvent } from './health/healthMetricsCollector.js'; +import { SerializedHealthMetrics } from './health/serializedTypes.js'; +import http from 'http' +import https from 'https' +import fs from 'fs' +import BaseError from 'modern-errors'; +import { Server } from 'socket.io'; + +export type SslConfig = { port: number, keyFile: string, certFile: string }; + +export interface AppOptions { + allowedOrigins: string[]; + dataPath: string; +} + +export interface ServeResult { + httpServer: http.Server; + httpsServer?: https.Server; +} + +export interface AppInstance { + websocket: WebsocketServer; + serve: (httpPort: number, sslConfig?: SslConfig) => ServeResult; + shutdown: () => Promise; +} + +const configureRoutes = (app: express.Application, container: Container): void => { + app.get('/devices', executeController(container, 'controller.getDevices')); + app.get('/device/:deviceId', executeController(container, 'controller.getDevice')); + app.patch('/device/:deviceId', executeController(container, 'controller.patchDevice')); + + app.get('/automation/scripts', executeController(container, 'controller.automation.getScripts')); + app.get('/automation/scripts/:fileName', executeController(container, 'controller.automation.getScript')); + + app.post('/automation/scripts/:fileName', executeController(container, 'controller.automation.createScript')); + app.delete('/automation/scripts/:fileName', executeController(container, 'controller.automation.deleteScript')); + + app.get('/automation/log', executeController(container, 'controller.automation.getLog')); + app.post('/automation/run', executeController(container, 'controller.automation.runScript')); + app.get('/automation/stop', executeController(container, 'controller.automation.stopScript')); + app.get('/automation/status', executeController(container, 'controller.automation.statusScript')); + + app.get('/settings', executeController(container, 'controller.settings.get')); + app.put('/settings', executeController(container, 'controller.settings.put')); + + app.get('/health', executeController(container, 'controller.health')); + app.get('/version', executeController(container, 'controller.version')); +} + +const configureWebsocket = (io: WebsocketServer, container: Container): void => { + const deviceManager = container.get('device.manager'); + const scriptRuntime = container.get('automation.scriptRuntime'); + const settingsManager = container.get('settings.manager'); + const serializer = container.get('serializer.classToPlain'); + const logger = container.get('logger.default'); + const healthMetricsCollector = container.get('health.metricsCollector'); + + const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); + + // Health metrics: start background refresh, broadcast over WebSocket on each collection + healthMetricsCollector.start(1000); + healthMetricsCollector.on(HealthMetricsCollectorEvent.collected, (metrics: SerializedHealthMetrics) => { + io.emit(WebSocketEvent.healthMetrics, metrics); + }); + + // Whenever someone connects this gets executed + io.on('connection', socket => { + logger.debug(`Client connected: ${socket.id}`); + + socket.on('disconnect', () => { + logger.debug(`Client disconnected: ${socket.id}`); + }); + + const deviceUpdateHandler = container.get('socket.deviceUpdateHandler'); + + socket.on(WebSocketEvent.deviceUpdateReceived, (data) => deviceUpdateHandler.handle(data)); + }); + + deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { + io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); + }); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { + io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceDisconnected, device); + }); + + deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { + io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceRefreshed, device); + }); + + settingsManager.on(SettingsEventType.changed, (settings: Settings) => { + io.emit(SettingsEventType.changed, serializer.transform(settings)); + }); + + // Automation events + scriptRuntime.on(AutomationEventType.consoleLog, (data: string) => io.emit(AutomationEventType.consoleLog, data)); +}; + +const loadDeviceProviders = (container: Container): void => { + const serialPortObserver = container.get('device.observer.serial'); + const logger = container.get('logger.default'); + const settings = container.get('settings'); + const deviceProviderManager = container.get('device.provider.loader'); + + deviceProviderManager.loadFromSettings(settings); + + deviceProviderManager + .startProviders() + .catch(e => logError(logger, `Loading device providers failed`, e)); + + serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e)); +}; + +const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({ + origin: (origin, callback): void => { + if (undefined === origin || allowedOrigins.length === 0) { + return callback(null, true); + } + return callback(null, allowedOrigins.includes(origin)); + }, +}); + +export const createContainer = (dataPath: string): Pimple => (new Pimple()) + .register(new LoggerServiceProvider()) + .register(new HealthServiceProvider()) + .register(new SettingsServiceProvider(dataPath)) + .register(new DeviceServiceProvider()) + .register(new ControllerServiceProvider()) + .register(new SocketServiceProvider()) + .register(new RepositoryServiceProvider(dataPath)) + .register(new SerializationServiceProvider()) + .register(new AutomationServiceProvider(dataPath)) + .register(new FactoryServiceProvider()) + .register(new SchemaValidationServiceProvider()) +; + +const getPortFromServer = (server: http.Server): number => { + const address = server.address(); + if (address === null || address === undefined || typeof address !== 'object') { + throw new Error('Could not obtain server address'); + } + + return address.port; +} + +export const createApp = (container: Container, options: AppOptions): AppInstance => { + const corsOptions = buildCorsOptions(options.allowedOrigins); + const websocketServer: WebsocketServer = new Server(undefined, { + cors: corsOptions, + }); + const app = express(); + + app + .use((req, res, next) => { + // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged + if (req.headers['access-control-request-private-network'] === 'true') { + res.header('Access-Control-Allow-Private-Network', 'true'); + } + + next(); + }) + .use(cors(corsOptions)) + .use(contentTypeMiddleware) + .use(express.json()) + .use(express.text()) + ; + + configureRoutes(app, container); + configureWebsocket(websocketServer, container); + loadDeviceProviders(container); + + + let serveResult: ServeResult | undefined; + let canBeShutDown = false; + + return { + websocket: websocketServer, + serve: (httpPort: number, sslConfig?: SslConfig): ServeResult => { + const logger = container.get('logger.default'); + const httpServer = http.createServer(app); + + websocketServer.attach(httpServer); + + serveResult = { httpServer, httpsServer: undefined }; + + httpServer.listen(httpPort, () => { + logger.info(`SlvCtrl+ server listening on http://localhost:${getPortFromServer(httpServer)}`); + }); + + if (sslConfig !== undefined) { + try { + const key = fs.readFileSync(sslConfig.keyFile); + const cert = fs.readFileSync(sslConfig.certFile); + const httpsServer = https.createServer({ key, cert }, app); + + websocketServer.attach(httpsServer); + + serveResult.httpsServer = httpsServer; + + httpsServer.listen(sslConfig.port, () => { + logger.info(`SlvCtrl+ server listening on https://localhost:${getPortFromServer(httpsServer)} (ssl)`); + }); + } catch (err) { + const baseError = BaseError.normalize(err); + logger.error(`Failed to load SSL certificates: ${baseError.message}`); + logger.warn('HTTPS server will not be started'); + } + } + + canBeShutDown = true; + + return serveResult; + }, + shutdown: async (): Promise => { + if (!canBeShutDown) { + return; + } + + canBeShutDown = false; + + const logger = container.get('logger.default'); + const closeServer = (server: http.Server): Promise => server.listening + ? new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve())) + : Promise.resolve(); + + logger.info('Shutting down...'); + + await container.get('automation.scriptRuntime').stop(); + container.get('device.observer.serial').stop(); + await container.get('device.provider.loader').stopProviders(); + container.get('health.metricsCollector').stop(); + + await websocketServer.close(); + + if (serveResult) { + await closeServer(serveResult.httpServer); + if (serveResult.httpsServer) { + await closeServer(serveResult.httpsServer); + } + } + }, + }; +} diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index b41bf57a..47364671 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -1,5 +1,5 @@ -import { NodeVM, VMScript } from 'vm2'; -import BaseError from 'modern-errors'; +import ivm from 'isolated-vm'; +import { transform } from 'sucrase'; import Device from '../device/device.js'; import DeviceRepositoryInterface from '../repository/deviceRepositoryInterface.js'; import fs, { WriteStream } from 'fs'; @@ -7,19 +7,17 @@ import readLastLines from 'read-last-lines/dist/index.js'; import EventEmitter from 'events'; import AutomationEventType from './automationEventType.js'; import { DeviceManagerEvent } from '../device/deviceManager.js'; +import { AttributeValue } from '../device/attribute/deviceAttribute.js'; +import Logger from '../logging/Logger.js'; -// @todo use own runtime events instead of device manager events, so that the script can decide which events to react to and which not -type SupportedDeviceEvent = { - type: Extract< - DeviceManagerEvent, - DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed - >|null, - device: Device|null, -} -type Sandbox = { - devices: DeviceRepositoryInterface, - event: SupportedDeviceEvent - context: { [key: string]: string } +type AutomationDeviceEvent = Extract< + DeviceManagerEvent, + DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed +>; + +export type SupportedDeviceEvent = { + type: AutomationDeviceEvent, + device: Device, } type ScriptRuntimeEvents = { @@ -28,94 +26,388 @@ type ScriptRuntimeEvents = { [AutomationEventType.scriptStopped]: () => void, } -export class ScriptRuntime +/** + * Bootstrap code injected into the isolate context once on load, before the user script runs. + * + * Globals prefixed with `__` are set from the host via `jail.set()` before this runs: + * - __log Reference – host console sink + * - __getAttribute Reference – async: (deviceId, attrName) => JSON string|null + * - __setAttribute Reference – async: (deviceId, attrName, value) => void + * - __getDeviceJson Reference – sync: (deviceId) => JSON string|null + * - __getDevicesJson Reference – sync: () => JSON string of [{id, name}] + * - __lifecycleDone Callback – signals onStart/onStop completion (null = ok, string = error) + * + * Script-facing API: + * - console.log(...) + * - onStart(handler) – run once when script loads: () => void | Promise + * - onStop(handler) – run once when script stops: () => void | Promise + * - onEvent(handler) – register event handler: handler receives { type, device } + * - event.type – string – current event type + * - event.device.getDeviceId – string + * - event.device.getDeviceName – string + * - event.device.getAttribute(name) – Promise<{ value, name, label, modifier, type } | undefined> + * - event.device.setAttribute(name, v) – Promise + * - devices.getById(id) – Device | null + * - devices.getAll() – Device[] + */ +const BOOTSTRAP_SCRIPT = ` +var console = { + log: (...args) => __log.applySync(undefined, ['log', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), + error: (...args) => __log.applySync(undefined, ['error', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), + warn: (...args) => __log.applySync(undefined, ['warn', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), + info: (...args) => __log.applySync(undefined, ['info', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), + debug: (...args) => __log.applySync(undefined, ['debug', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), + trace: (...args) => __log.applySync(undefined, ['trace', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), +}; + +async function __resolveAttr(deviceId, attributeName) { + const json = await __getAttribute.apply( + undefined, + [deviceId, attributeName], + { arguments: { copy: true }, result: { copy: true, promise: true } } + ); + return json !== null ? JSON.parse(json) : null; +} + +function __createDeviceProxy(id, name) { + return Object.freeze({ + get getDeviceId() { return id; }, + get getDeviceName() { return name; }, + async getAttribute(attributeName) { + const attr = await __resolveAttr(id, attributeName); + return attr ?? undefined; + }, + setAttribute(attributeName, value) { + __setAttribute.applySync(undefined, [id, attributeName, value], { arguments: { copy: true } }); + return Promise.resolve(); + } + }); +} + +var devices = Object.freeze({ + getById(deviceId) { + const json = __getDeviceJson.applySync(undefined, [deviceId], { arguments: { copy: true }, result: { copy: true } }); + if (json === null) return null; + const { id, name } = JSON.parse(json); + return __createDeviceProxy(id, name); + }, + getAll() { + return JSON.parse(__getDevicesJson.applySync(undefined, [], { result: { copy: true } })) + .map(({ id, name }) => __createDeviceProxy(id, name)); + } +}); + +var __handler = null; + +function onEvent(fn) { + __handler = fn; +} + +var __startHandler = null; +var __stopHandler = null; + +function onStart(fn) { + __startHandler = fn; +} + +function onStop(fn) { + __stopHandler = fn; +} + +var __dispatchLifecycle = function(phase) { + var handler = phase === 'start' ? __startHandler : __stopHandler; + if (handler === null) { __lifecycleDone(null); return; } + var result; + try { + result = handler(); + } catch (err) { + __lifecycleDone(String(err)); + return; + } + if (result !== null && result !== undefined && typeof result.then === 'function') { + result.then(function() { __lifecycleDone(null); }, function(err) { __lifecycleDone(String(err)); }); + } else { + __lifecycleDone(null); + } +}; + +// __done is an ivm.Callback set by the host that signals event-handler completion. +// It is called with null on success, or an error string on failure. +var __dispatchEvent = function(eventType, deviceId, deviceName) { + if (__handler === null) { __done(null); return; } + const event = Object.freeze({ + type: eventType, + device: __createDeviceProxy(deviceId, deviceName) + }); + var result; + try { + result = __handler(event); + } catch (err) { + __done(String(err)); + return; + } + if (result !== null && result !== undefined && typeof result.then === 'function') { + result.then(() => __done(null), (err) => __done(String(err))); + } else { + __done(null); + } +}; +`; + +export default class ScriptRuntime { private readonly eventEmitter: EventEmitter; - private scriptCode: VMScript|null = null; + private isolate: ivm.Isolate|null = null; - private vm: NodeVM|null = null; + private vmContext: ivm.Context|null = null; - private sandbox: Sandbox|null = null; + private dispatchRef: ivm.Reference|null = null; + + private lifecycleRef: ivm.Reference|null = null; + + private pendingEventDone: ((errMsg: string | null) => void) | null = null; + + private pendingLifecycleDone: ((errMsg: string | null) => void) | null = null; private readonly deviceRepository: DeviceRepositoryInterface; private readonly logPath: string; + private readonly logger: Logger; + private logWriter: WriteStream|null = null; private runningSince: Date|null = null; - public constructor(deviceRepository: DeviceRepositoryInterface, logPath: string, eventEmitter: EventEmitter) { + private eventQueue: (() => Promise)[] = []; + + private processQueuePromise: Promise | null = null; + + public constructor(deviceRepository: DeviceRepositoryInterface, logPath: string, eventEmitter: EventEmitter, logger: Logger) { this.eventEmitter = eventEmitter; this.deviceRepository = deviceRepository; this.logPath = logPath; + this.logger = logger.child({ name: ScriptRuntime.name }); } - public load(scriptCode: string): void + public async load(scriptCode: string): Promise { - this.scriptCode = new VMScript(scriptCode); + this.isolate = new ivm.Isolate({ memoryLimit: 128 }); + this.vmContext = await this.isolate.createContext(); - this.sandbox = { - event: { type: null, device: null }, - devices: this.deviceRepository, - context: {}, - } + const jail = this.vmContext.global; - this.vm = new NodeVM({ - console: 'redirect', - require: { - external: true, - root: './', - }, - sandbox: this.sandbox - }); + const loggerMethods: Record void> = { + log: (msg) => this.logger.info(msg), + error: (msg) => this.logger.error(msg), + warn: (msg) => this.logger.warn(msg), + info: (msg) => this.logger.info(msg), + debug: (msg) => this.logger.debug(msg), + trace: (msg) => this.logger.trace(msg), + }; - this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`) + await jail.set('__log', new ivm.Reference((level: string, msg: string) => { + const str = String(msg); + (loggerMethods[level] ?? this.logger.info.bind(this.logger))(`Automation script: ${str}`); + this.log(str); + this.eventEmitter.emit(AutomationEventType.consoleLog, str); + })); - this.vm.on('console.log', (data: string) => { - console.log(`VM stdout: ${data}`); - void this.log(data); - this.eventEmitter.emit(AutomationEventType.consoleLog, data); - }); + await jail.set('__getAttribute', new ivm.Reference(async (deviceId: string, attrName: string): Promise => { + const dev = this.deviceRepository.getById(deviceId); + if (dev === null) return null; + const attr = await dev.getAttribute(attrName); + if (attr === undefined) return null; + return JSON.stringify({ value: attr.value ?? null, name: attr.name, label: attr.label ?? null, modifier: attr.modifier, type: attr.getType() }); + })); + + await jail.set('__getDeviceJson', new ivm.Reference((deviceId: string): string | null => { + const dev = this.deviceRepository.getById(deviceId); + if (dev === null) return null; + return JSON.stringify({ id: dev.getDeviceId, name: dev.getDeviceName }); + })); + + await jail.set('__setAttribute', new ivm.Reference((deviceId: string, attrName: string, value: AttributeValue): void => { + const dev = this.deviceRepository.getById(deviceId); + if (dev === null) return; + dev.setAttribute(attrName, value).catch((e: unknown) => console.error('VM setAttribute failed:', e)); + })); + + await jail.set('__getDevicesJson', new ivm.Reference((): string => { + return JSON.stringify( + this.deviceRepository.getAll().map(d => ({ + id: d.getDeviceId, + name: d.getDeviceName, + })) + ); + })); + + await jail.set('__done', new ivm.Callback((errMsg: string | null) => { + if (this.pendingEventDone !== null) { + const done = this.pendingEventDone; + this.pendingEventDone = null; + done(errMsg); + } + }, { async: true })); + + await jail.set('__lifecycleDone', new ivm.Callback((errMsg: string | null) => { + if (this.pendingLifecycleDone !== null) { + const done = this.pendingLifecycleDone; + this.pendingLifecycleDone = null; + done(errMsg); + } + }, { async: true })); + const compiledBootstrap = this.isolate.compileScriptSync(BOOTSTRAP_SCRIPT); + const { code: transpiledScript } = transform(scriptCode, { transforms: ['typescript'] }); + const compiledScript = this.isolate.compileScriptSync(transpiledScript); + + await compiledBootstrap.run(this.vmContext); + await compiledScript.run(this.vmContext, { promise: true }); + + this.dispatchRef = await this.vmContext.global.get('__dispatchEvent'); + this.lifecycleRef = await this.vmContext.global.get('__dispatchLifecycle'); + + this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`); + this.logWriter.on('error', (err) => { + this.logger.error(`Automation log write error: ${err.message}`); + }); this.runningSince = new Date(); + const lifecycleRef = this.lifecycleRef; + if (lifecycleRef === null) { + throw new Error('lifecycleRef not initialized'); + } + + try { + await new Promise((resolve, reject) => { + this.pendingLifecycleDone = (errMsg: string | null): void => { + if (errMsg !== null) reject(new Error(errMsg)); + else resolve(); + }; + void lifecycleRef.apply(undefined, ['start'], { arguments: { copy: true } }); + }); + } catch (e) { + await this.stop(); + throw e; + } + this.eventEmitter.emit(AutomationEventType.scriptStarted); - console.log('script loaded') + this.logger.info('script loaded'); } - public stop(): void + public async stop(): Promise { - this.vm = null; - this.sandbox = null; + if (this.isRunning() === false) { + return; + } + + // Null dispatchRef first so runForEvent() returns early for any events + // arriving during teardown, and queued-but-not-started tasks resolve immediately. + this.dispatchRef = null; + + // Unblock any in-flight event handler so processQueue() can exit its await + if (this.pendingEventDone !== null) { + const done = this.pendingEventDone; + this.pendingEventDone = null; + done('script stopped'); + } + this.eventQueue = []; + + // Wait for the processQueue coroutine to finish before tearing down the isolate + if (this.processQueuePromise !== null) { + await this.processQueuePromise; + this.processQueuePromise = null; + } + + const lifecycleRef = this.lifecycleRef; + if (lifecycleRef !== null) { + try { + await new Promise((resolve, reject) => { + this.pendingLifecycleDone = (errMsg: string | null): void => { + if (errMsg !== null) reject(new Error(errMsg)); + else resolve(); + }; + void lifecycleRef.apply(undefined, ['stop'], { arguments: { copy: true } }); + }); + } catch (e: unknown) { + this.logger.error('onStop error:', e instanceof Error ? e.message : String(e)); + } + } + + this.lifecycleRef = null; + this.pendingEventDone = null; + this.pendingLifecycleDone = null; + + if (this.vmContext !== null) { + this.vmContext.release(); + this.vmContext = null; + } + + if (this.isolate !== null) { + this.isolate.dispose(); + this.isolate = null; + } + this.runningSince = null; - if (null !== this.logWriter) { - this.logWriter.close(); + if (this.logWriter !== null) { + const writer = this.logWriter; + this.logWriter = null; + await new Promise(resolve => writer.close(() => resolve())); } this.eventEmitter.emit(AutomationEventType.scriptStopped); - console.log('script stopped') + this.logger.info('script stopped'); } public runForEvent(eventType: SupportedDeviceEvent['type'], device: Device): void { - if (null === this.vm || null === this.sandbox || null === this.scriptCode) { + if (null === this.dispatchRef) { return; } - this.sandbox.event.type = eventType; - this.sandbox.event.device = device; + this.eventQueue.push(() => new Promise((resolve, reject) => { + if (this.dispatchRef === null) { + resolve(); + return; + } - try { - this.vm.run(this.scriptCode); - } catch (e: unknown) { - const error = BaseError.normalize(e); - console.error(`VM stdout: ${error.message}`); - void this.log(error.message); - this.eventEmitter.emit(AutomationEventType.consoleLog, error.toString()); + this.pendingEventDone = (errMsg: string | null): void => { + if (errMsg !== null) { + reject(new Error(errMsg)); + } else { + resolve(); + } + }; + void this.dispatchRef.apply( + undefined, + [eventType, device.getDeviceId, device.getDeviceName], + { arguments: { copy: true } } + ); + })); + + if (this.processQueuePromise === null) { + this.processQueuePromise = this.processQueue(); + } + } + + private async processQueue(): Promise + { + while (this.eventQueue.length > 0) { + const task = this.eventQueue.shift()!; + try { + await task(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + this.logger.error(`VM error: ${msg}`); + this.log(msg); + this.eventEmitter.emit(AutomationEventType.consoleLog, String(e)); + } } + + this.processQueuePromise = null; } public async getLog(maxLines: number): Promise @@ -145,6 +437,10 @@ export class ScriptRuntime this.eventEmitter.on(event, listener); return this; } -} -export default ScriptRuntime; + public off (event: E, listener: ScriptRuntimeEvents[E]): this + { + this.eventEmitter.off(event, listener); + return this; + } +} diff --git a/src/controller/automation/runScriptController.ts b/src/controller/automation/runScriptController.ts index b96f9259..b347724d 100644 --- a/src/controller/automation/runScriptController.ts +++ b/src/controller/automation/runScriptController.ts @@ -13,7 +13,7 @@ export default class RunScriptController implements ControllerInterface this.scriptRuntime = scriptRuntime; } - public execute(req: RunScriptRequest, res: Response): void + public async execute(req: RunScriptRequest, res: Response): Promise { const matchedContentType = req.is('text/plain'); @@ -24,7 +24,7 @@ export default class RunScriptController implements ControllerInterface const scriptCode = req.body; - this.scriptRuntime.load(scriptCode); + await this.scriptRuntime.load(scriptCode); const response = { running: this.scriptRuntime.isRunning(), diff --git a/src/controller/automation/stopScriptController.ts b/src/controller/automation/stopScriptController.ts index 5e88e1c2..76749b86 100644 --- a/src/controller/automation/stopScriptController.ts +++ b/src/controller/automation/stopScriptController.ts @@ -11,9 +11,9 @@ export default class StopScriptController implements ControllerInterface this.scriptRuntime = scriptRuntime; } - public execute(req: Request, res: Response): void + public async execute(req: Request, res: Response): Promise { - this.scriptRuntime.stop(); + await this.scriptRuntime.stop(); res.sendStatus(200); } diff --git a/src/controller/getDeviceController.ts b/src/controller/getDeviceController.ts index 470ecf32..94814d4e 100644 --- a/src/controller/getDeviceController.ts +++ b/src/controller/getDeviceController.ts @@ -3,6 +3,7 @@ import ControllerInterface from './controllerInterface.js'; import ClassToPlainSerializer from '../serialization/classToPlainSerializer.js'; import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.js'; import DeviceDiscriminator from '../serialization/discriminator/deviceDiscriminator.js'; +import { DeviceId } from '../device/deviceId.js'; type GetDeviceRequest = Request<{ deviceId: string }>; @@ -21,7 +22,7 @@ export default class GetDeviceController implements ControllerInterface public execute(req: GetDeviceRequest, res: Response): void { const { deviceId } = req.params; - const device = this.connectedDeviceRepository.getById(deviceId); + const device = this.connectedDeviceRepository.getById(DeviceId.create(deviceId)); if (null === device) { res.sendStatus(404); diff --git a/src/controller/healthController.ts b/src/controller/healthController.ts index 855ac8c0..20bdfa88 100644 --- a/src/controller/healthController.ts +++ b/src/controller/healthController.ts @@ -1,39 +1,25 @@ import { Request, Response } from 'express'; import ControllerInterface from './controllerInterface.js'; -import process from 'process'; -import osu from 'node-os-utils'; +import HealthMetricsCollector from '../health/healthMetricsCollector.js'; export default class HealthController implements ControllerInterface { - public async execute(req: Request, res: Response): Promise + private readonly healthMetricsCollector; + + public constructor(healthMetricsCollector: HealthMetricsCollector) { - const healthInfo: {[key: string]: any} = { - process: { - memoryUsage: process.memoryUsage(), - }, - system: { - cpu: { - usage: await osu.cpu.usage(100), - average: osu.cpu.average(), - cores: osu.cpu.count(), - model: osu.cpu.model(), - }, - memory: await osu.mem.info(), - os: { - name: osu.os.oos(), - type: osu.os.type(), - arch: osu.os.arch(), - platform: osu.os.platform(), - }, - network: { - netstat: await osu.netstat.stats() - }, - ip: osu.os.ip(), - hostname: osu.os.hostname(), - uptime: osu.os.uptime(), - } - }; + this.healthMetricsCollector = healthMetricsCollector; + } + + public execute(_req: Request, res: Response): void + { + const metrics = this.healthMetricsCollector.collect(); + + if (metrics === null) { + res.sendStatus(204); + return; + } - res.json(healthInfo); + res.json(metrics); } } diff --git a/src/controller/patchDeviceController.ts b/src/controller/patchDeviceController.ts index 15eef4f6..c88b6509 100644 --- a/src/controller/patchDeviceController.ts +++ b/src/controller/patchDeviceController.ts @@ -4,6 +4,7 @@ import ControllerInterface from './controllerInterface.js'; import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.js'; import DeviceUpdaterInterface from '../device/updater/deviceUpdaterInterface.js'; import { DeviceData } from '../device/device.js'; +import { DeviceId } from '../device/deviceId.js'; type PatchDeviceRequest = Request<{ deviceId: string }, any, DeviceData>; @@ -22,7 +23,7 @@ export default class PatchDeviceController implements ControllerInterface public async execute(req: PatchDeviceRequest, res: Response): Promise { const { deviceId } = req.params; - const device = this.connectedDeviceRepository.getById(deviceId); + const device = this.connectedDeviceRepository.getById(DeviceId.create(deviceId)); if (null === device) { res.sendStatus(404); diff --git a/src/device/attribute/intRangeDeviceAttribute.ts b/src/device/attribute/intRangeDeviceAttribute.ts index 1301179f..30ad5595 100644 --- a/src/device/attribute/intRangeDeviceAttribute.ts +++ b/src/device/attribute/intRangeDeviceAttribute.ts @@ -13,7 +13,7 @@ export default class IntRangeDeviceAttribute void }[]> = new Map(); + private readonly detectedDeviceAcquireQueue: Map void }[]> = new Map(); - private readonly connectedDevices: Map>; + private readonly connectedDevices: Map>; - public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { + public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { this.eventEmitter = eventEmitter; this.logger = logger.child({ name: DeviceManager.name }); this.connectedDevices = connectedDevices; @@ -133,14 +133,14 @@ export default class DeviceManager this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId}' has been claimed by another provider`); } - public getConnectedDevices(): Device[] + public getConnectedDevices(): Device[] { return Array.from(this.connectedDevices.values()); } - public getConnectedDevice(uuid: string): Device|null + public getConnectedDevice(deviceId: DeviceId): Device|null { - const device = this.connectedDevices.get(uuid); + const device = this.connectedDevices.get(deviceId); return undefined !== device ? device : null; } @@ -153,6 +153,25 @@ export default class DeviceManager this.eventEmitter.on(event, listener); } + public off( + event: T, + listener: (...args: DeviceManagerEventMap[T]) => void + ): void + { + this.eventEmitter.off(event, listener); + } + + public async reset(): Promise + { + for (const [, device] of this.connectedDevices) { + await device.close(); + } + + for (const [deviceId] of this.detectedDeviceAcquireQueue) { + this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); + } + } + private clearDetectedDeviceAcquireQueue(deviceId: DeviceId, reason: string): void { for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index b1271f63..1294552f 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -47,14 +47,14 @@ export default class AiroticDevice extends BleDevice { const { restColor, breathInColor } = this.attributes; - if (restColor.value) { + if (undefined !== restColor.value) { const { r, g, b } = this.parseColor(restColor.value); await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); await sleep(100); await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); } - if (breathInColor.value) { + if (undefined !== breathInColor.value) { const { r, g, b } = this.parseColor(breathInColor.value); await this.messageResponseHandler.send(AiroticProtocol.createSelectBreathInColorMessage()); await sleep(100); diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index e26dc330..754b01cf 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -3,8 +3,7 @@ import BaseError from 'modern-errors'; import DeviceManager from '../../deviceManager.js'; import AiroticDevice from './airoticDevice.js'; import Logger from '../../../logging/Logger.js'; -import { asyncHandler, promiseWithTimeout } from '../../../util/async.js'; -import { logError } from '../../../util/error.js'; +import { promiseWithTimeout } from '../../../util/async.js'; import { BleDeviceInfo } from '../../transport/bleObserver.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airtonicProtocol.js'; @@ -16,7 +15,6 @@ import KnownDevice from '../../../settings/knownDevice.js'; import { DeviceId } from '../../deviceId.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; -import BleDevice from '../../bleDevice.js'; export default class AiroticDeviceProvider extends BleDeviceProvider { diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index d38ac7d2..75cf0216 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -41,6 +41,10 @@ export default class ButtplugIoDevice extends Device this.deviceModel = deviceModel; } + public override get getRefreshInterval(): number { + return 100; + } + protected override async doRefresh(): Promise { for (const sensor of this.buttplugClientDevice.messageAttributes.SensorReadCmd ?? []) { const value = await this.buttplugClientDevice.sensorRead(sensor.Index, sensor.SensorType); diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index 5b57be56..ea5a4e43 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -85,14 +85,14 @@ export default class ButtplugIoDeviceFactory // A range is defined by two numbers, if there are more or less, let's fallback // to a normal integer attribute. Not that dramatic for a sensor after all. - if (item.StepRange.length === 2) { + if ('SensorRange' in item && Array.isArray(item.SensorRange) && item.SensorRange.length === 2) { attributes[attrName] = IntRangeDeviceAttribute.createInitialized( `${item.SensorType}-${item.Index}`, item.FeatureDescriptor, DeviceAttributeModifier.readOnly, undefined, - Int.from(item.StepRange[0]), - Int.from(item.StepRange[1]), + Int.from(item.SensorRange[0]), + Int.from(item.SensorRange[1]), Int.from(1), Int.ZERO ); diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 40fcb38f..6438929d 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -8,6 +8,7 @@ import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; import SlvCtrlPlusButtplugWebsocketClientConnector from './slvCtrlPlusButtplugWebsocketClientConnector.js'; import DeviceManager from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; +import { hasProperty } from '../../../util/objects.js'; export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { public static readonly providerName = 'buttplugIoWebsocket'; @@ -78,7 +79,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, 60000); } } catch (e: unknown) { - logError(this.logger, `Could not connect to buttplug.io server (${url})`, e); + logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); } } diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index bcdb0245..e16f64c6 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -1,4 +1,6 @@ -import { ReadlineParser, SerialPort } from 'serialport'; +import { ReadlineParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface } from '@serialport/bindings-interface'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; @@ -34,7 +36,7 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); const transport = this.transportFactory.create(syncPort, undefined, Buffer.from('\r')); diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts index 901d5d23..0c64d048 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts @@ -40,7 +40,7 @@ export default abstract class SlvCtrlPlusDevice< protected async send(command: SlvCtrlProtocolCommand): Promise { const encodedCommand = this.protocol.encode(command); - const response = await this.transport.sendAndAwaitReceive(encodedCommand); + const response = await this.transport.sendAndAwaitReceive(encodedCommand, SlvCtrlProtocol.transportTimeoutMs); const decodedResponse = this.protocol.decode(response); if ('error' in decodedResponse) { diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts index d074919a..8edc1643 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts @@ -1,5 +1,6 @@ -import { ReadlineParser, ReadyParser, SerialPort } from 'serialport'; -import type { PortInfo } from '@serialport/bindings-interface'; +import { ReadlineParser, ReadyParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; import SlvCtrlPlusDeviceFactory from './slvCtrlPlusDeviceFactory.js'; import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; import EventEmitter from 'events'; @@ -39,7 +40,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide this.deviceTransportFactory = deviceTransportFactory; } - protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPort): Promise + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); @@ -79,7 +80,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide return { baudRate: 9600 }; } - protected override preparePort(port: SerialPort, portInfo: PortInfo): Promise { + protected override preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { return new Promise((resolve, reject) => { if (portInfo.vendorId !== SlvCtrlPlusSerialDeviceProvider.arduinoVendorId) { // It's NOT an Arduino diff --git a/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts b/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts index 546db33c..55578cf0 100644 --- a/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts @@ -27,10 +27,14 @@ export default class RandomGeneratorVirtualDeviceLogic extends VirtualDeviceLogi return 100; } - public async refreshData( - device: VirtualDevice - ): Promise { - const newNumber = Math.floor(Math.random() * (this.config.max - this.config.min + 1)) + this.config.min; + public async refreshData(device: VirtualDevice): Promise { + const currentNumber = (await device.getAttribute('value'))?.value; + let newNumber: number; + + do { + newNumber = Math.floor(Math.random() * (this.config.max - this.config.min + 1)) + this.config.min; + } while (currentNumber !== undefined && newNumber === currentNumber); + await device.setAttribute('value', Int.from(newNumber)); } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 68b36aed..2d871fb6 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -21,6 +21,8 @@ export default class VirtualDeviceProvider extends DeviceProvider private readonly settingsManager: SettingsManager; + private readonly scanIntervalMs: number; + private discoveryInterval?: NodeJS.Timeout; public constructor( @@ -28,19 +30,27 @@ export default class VirtualDeviceProvider extends DeviceProvider eventEmitter: EventEmitter, deviceFactory: VirtualDeviceFactory, settingsManager: SettingsManager, - logger: Logger + logger: Logger, + scanIntervalMs: number ) { super(deviceManager, eventEmitter, logger.child({ name: VirtualDeviceProvider.name })); this.deviceFactory = deviceFactory; this.settingsManager = settingsManager; + this.scanIntervalMs = scanIntervalMs; } public override async init(): Promise { - // Scan for new virtual devices every 3 seconds this.discoveryInterval ??= setImmediateInterval(asyncHandler( this.discoverVirtualDevices.bind(this), (e: unknown) => this.logger.error('Error while scanning for new virtual devices', e) - ), 3000); + ), this.scanIntervalMs); + } + + public override async stop(): Promise { + if (this.discoveryInterval !== undefined) { + clearInterval(this.discoveryInterval); + this.discoveryInterval = undefined; + } } private async discoverVirtualDevices(): Promise { diff --git a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts index c6e23c78..a3e6a419 100644 --- a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts +++ b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts @@ -6,8 +6,14 @@ import VirtualDeviceFactory from './virtualDeviceFactory.js'; import DeviceManager from '../../deviceManager.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; +type VirtualDeviceProviderConfig = { + scanIntervalMs: number, +} + export default class VirtualDeviceProviderFactory implements DeviceProviderFactory { + private static readonly DEFAULT_SCAN_INTERVAL_MS = 3000; + private readonly deviceManager: DeviceManager; private readonly eventEmitterFactory: EventEmitterFactory; @@ -32,13 +38,18 @@ export default class VirtualDeviceProviderFactory implements DeviceProviderFacto this.logger = logger; } - public create(): VirtualDeviceProvider { + public create(config: VirtualDeviceProviderConfig): VirtualDeviceProvider { + const scanIntervalMs = typeof config.scanIntervalMs === 'number' && config.scanIntervalMs > 0 + ? config.scanIntervalMs + : VirtualDeviceProviderFactory.DEFAULT_SCAN_INTERVAL_MS; + return new VirtualDeviceProvider( this.deviceManager, this.eventEmitterFactory.create(), this.deviceFactory, this.settingsManager, - this.logger + this.logger, + scanIntervalMs, ); } } diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index 225f6dc3..71703ee3 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -1,4 +1,5 @@ -import { SerialPort } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface } from '@serialport/bindings-interface'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; @@ -36,7 +37,7 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise { const serialLogger = this.logger.child({ name: Zc95Device.name }) const parser = port.pipe(new FrameParser({ stx: Zc95Protocol.STX, etx: Zc95Protocol.ETX })); @@ -76,10 +77,10 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { + private async reset(port: SerialPortStream, close: boolean = false): Promise { return new Promise((resolve, reject) => { - port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr) => { - if (writeErr) { + port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr: Error | null | undefined) => { + if (null != writeErr) { reject(writeErr); return; } @@ -87,8 +88,8 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider EOT'); if (close) { - port.close((closeErr) => { - if (closeErr) { + port.close((closeErr: Error | null) => { + if (null != closeErr) { reject(closeErr); return; } diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index eeada07b..5292c4ad 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -19,4 +19,8 @@ export default abstract class DeviceProvider public async init(): Promise { return Promise.resolve(); } + + public async stop(): Promise { + return Promise.resolve(); + } } diff --git a/src/device/provider/deviceProviderLoader.ts b/src/device/provider/deviceProviderLoader.ts deleted file mode 100644 index 80defee7..00000000 --- a/src/device/provider/deviceProviderLoader.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Settings from '../../settings/settings.js'; -import DeviceProviderFactory from './deviceProviderFactory.js'; -import Logger from '../../logging/Logger.js'; - -export default class DeviceProviderLoader -{ - private settings: Settings; - - private factories: Map>; - - private readonly logger: Logger; - - public constructor( - settings: Settings, - factories: Map>, - logger: Logger - ) { - this.settings = settings; - this.factories = factories; - this.logger = logger.child({ name: DeviceProviderLoader.name }); - } - - public async loadFromSettings(): Promise - { - const configuredDeviceSources = this.settings.getDeviceSources(); - - this.logger.debug(`Found ${configuredDeviceSources.size} configured device source(s)`); - - for (const [id, deviceSource] of configuredDeviceSources) { - const factory = this.factories.get(deviceSource.type) - - if (undefined === factory) { - this.logger.warn(`Device source with id ${id} and type ${deviceSource.type} is not supported`); - continue; - } - - const provider = factory.create(deviceSource.config); - - await provider.init(); - } - } -} diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts new file mode 100644 index 00000000..1620a8e1 --- /dev/null +++ b/src/device/provider/deviceProviderManager.ts @@ -0,0 +1,64 @@ +import Settings from '../../settings/settings.js'; +import DeviceProviderFactory from './deviceProviderFactory.js'; +import Logger from '../../logging/Logger.js'; +import DeviceProvider from './deviceProvider.js'; + +export default class DeviceProviderManager +{ + private factories: Map>; + + private readonly logger: Logger; + + private providers: DeviceProvider[] = []; + + public constructor( + factories: Map>, + logger: Logger + ) { + this.factories = factories; + this.logger = logger.child({ name: DeviceProviderManager.name }); + } + + public loadFromSettings(settings: Settings): void + { + const configuredDeviceSources = settings.getDeviceSources(); + + this.logger.debug(`Found ${configuredDeviceSources.size} configured device source(s)`); + + for (const [id, deviceSource] of configuredDeviceSources) { + const factory = this.factories.get(deviceSource.type) + + if (undefined === factory) { + this.logger.warn(`Device source with id ${id} and type ${deviceSource.type} is not supported`); + continue; + } + + const provider = factory.create(deviceSource.config); + + this.providers.push(provider); + } + } + + public async startProviders(): Promise { + for (const provider of this.providers) { + await provider.init(); + } + } + + public async stopProviders(): Promise { + const errors: unknown[] = []; + + for (const provider of this.providers) { + try { + await provider.stop(); + } catch (error: unknown) { + errors.push(error); + this.logger.error('Failed to stop device provider', error); + } + } + + if (errors.length > 0) { + throw new Error(`Failed to stop ${errors.length} device provider(s)`); + } + } +} diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 6ce12d77..15db19a7 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -1,8 +1,9 @@ import DeviceProvider from './deviceProvider.js'; import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; -import { PortInfo } from '@serialport/bindings-interface'; -import { SerialPort, SerialPortOpenOptions } from 'serialport'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import { SerialPortOpenOptions } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; import SerialPortFactory from '../../factory/serialPortFactory.js'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; @@ -26,18 +27,19 @@ export default abstract class SerialDeviceProvider< private connectedDevices: Map = new Map(); + private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; + protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; - this.deviceManager.on( - DeviceManagerEvent.deviceDetected, - asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ) + this.deviceDetectedListener = asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) ); + + this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { @@ -49,7 +51,7 @@ export default abstract class SerialDeviceProvider< const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - if (!acquireResult.successful) { + if (false === acquireResult.successful) { this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); return; } @@ -136,11 +138,15 @@ export default abstract class SerialDeviceProvider< } // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected preparePort(port: SerialPort, portInfo: PortInfo): Promise { + protected preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { return Promise.resolve(); } - protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPort): Promise; + public override async stop(): Promise { + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + } + + protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPortStream): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/serializedTypes.ts b/src/device/serializedTypes.ts new file mode 100644 index 00000000..2c407fda --- /dev/null +++ b/src/device/serializedTypes.ts @@ -0,0 +1,102 @@ +import DeviceState from './deviceState.js'; +import { DeviceAttributeModifier } from './attribute/deviceAttribute.js'; + +type SerializedDeviceAttributeBase = { + name: string; + label: string | undefined; + modifier: DeviceAttributeModifier; + type: string; +} + +export type SerializedIntRangeDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'range'; + value: number | undefined; + min: number; + max: number; + incrementStep: number; + uom: string | undefined; +} + +export type SerializedIntDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'int'; + value: number | undefined; + uom: string | undefined; +} + +export type SerializedFloatDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'float'; + value: number | undefined; + uom: string | undefined; +} + +export type SerializedBoolDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'bool'; + value: boolean | undefined; +} + +export type SerializedStrDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'str'; + value: string | undefined; +} + +export type SerializedListDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'list'; + value: string | number | undefined; + values: { key: string | number; value: string | number }[]; +} + +export type SerializedDeviceAttribute = + | SerializedIntRangeDeviceAttribute + | SerializedIntDeviceAttribute + | SerializedFloatDeviceAttribute + | SerializedBoolDeviceAttribute + | SerializedStrDeviceAttribute + | SerializedListDeviceAttribute; + +type SerializedDeviceBase = { + connectedSince: Date; + deviceId: string; + deviceName: string; + provider: string; + state: DeviceState; + errorInfo: { reason: string; occurredAt: Date } | undefined; + controllable: boolean; + lastRefresh: Date | undefined; + attributes: Record; + config: Record; +} + +export type SerializedSlvCtrlPlusDevice = SerializedDeviceBase & { + type: 'slvCtrlPlus'; + deviceModel: string; + fwVersion: number; + protocolVersion: number; +} + +export type SerializedButtplugIoDevice = SerializedDeviceBase & { + type: 'buttplugIo'; + deviceModel: string; +} + +export type SerializedVirtualDevice = SerializedDeviceBase & { + type: 'virtual'; + deviceModel: string; + fwVersion: string; +} + +export type SerializedZc95Device = SerializedDeviceBase & { + type: 'zc95'; + fwVersion: string; +} + +export type SerializedEStim2bDevice = SerializedDeviceBase & { + type: 'estim2b'; + fwVersion: string; +} + +export type SerializedDevice = + | SerializedSlvCtrlPlusDevice + | SerializedButtplugIoDevice + | SerializedVirtualDevice + | SerializedZc95Device + | SerializedEStim2bDevice; diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 5d7a1935..3e8aeaa3 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -2,7 +2,7 @@ import { SerialPort } from 'serialport'; import { PortInfo } from '@serialport/bindings-interface'; import Logger from '../../logging/Logger.js'; import DeviceManager, { DeviceInfo } from '../deviceManager.js'; -import { setIntervalAsync } from '../../util/async.js'; +import { usb } from 'usb'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; @@ -21,6 +21,12 @@ export default class SerialPortObserver private managedDevices: Map = new Map(); + private onUsbEventRef?: () => void; + + private rescanTimer?: NodeJS.Timeout; + + private discoveryInFlight = false; + public constructor( deviceManager: DeviceManager, logger: Logger @@ -29,19 +35,35 @@ export default class SerialPortObserver this.logger = logger.child({ name: SerialPortObserver.name }); } - public async init(): Promise + public async start(): Promise { - return new Promise((resolve) => { - // Scan for new serial devices every 3 seconds - setIntervalAsync(async () => await this.discoverSerialDevices(), { - intervalMs: 3000, - onError: (e: unknown) => logError(this.logger, 'Error while scanning for new serial devices', e), - }); - resolve(); - }) + await this.discoverSerialDevices(); + + this.onUsbEventRef = (): void => { + this.logger.debug('USB event detected, scanning for serial devices in 1s...'); + + if (this.rescanTimer !== undefined) { + clearTimeout(this.rescanTimer); + } + + this.rescanTimer = setTimeout(() => { + if (this.discoveryInFlight) { + return; + } + this.discoveryInFlight = true; + this.discoverSerialDevices() + .catch(e => logError(this.logger, 'Error while scanning for new serial devices', e)) + .finally(() => { + this.discoveryInFlight = false; + }); + }, 1000); + }; + + usb.addEventListener('connect', this.onUsbEventRef); + usb.addEventListener('disconnect', this.onUsbEventRef); } - private async discoverSerialDevices(): Promise + public async discoverSerialDevices(): Promise { const foundDevices: Map = new Map(); @@ -87,4 +109,17 @@ export default class SerialPortObserver logError(this.logger, 'Could not list serial ports', err); } } + + public stop(): void { + if (this.rescanTimer !== undefined) { + clearTimeout(this.rescanTimer); + this.rescanTimer = undefined; + } + + if (this.onUsbEventRef !== undefined) { + usb.removeEventListener('connect', this.onUsbEventRef); + usb.removeEventListener('disconnect', this.onUsbEventRef); + this.onUsbEventRef = undefined; + } + } } diff --git a/src/device/webSocketEvent.ts b/src/device/webSocketEvent.ts index 1e3def73..10993dd1 100644 --- a/src/device/webSocketEvent.ts +++ b/src/device/webSocketEvent.ts @@ -3,6 +3,7 @@ enum WebSocketEvent { deviceConnected = 'deviceConnected', deviceDisconnected = 'deviceDisconnected', deviceRefreshed = 'deviceRefreshed', + healthMetrics = 'healthMetrics', } export default WebSocketEvent; diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..7e9c26ac --- /dev/null +++ b/src/env.ts @@ -0,0 +1,32 @@ +import { Value } from '@sinclair/typebox/value'; +import { Static, Type } from '@sinclair/typebox'; +import os from 'os'; + +const EnvSchema = Type.Object({ + PORT: Type.Number({ default: 1337, minimum: 1, maximum: 65535 }), + HTTPS_PORT: Type.Number({ default: 1338, minimum: 1, maximum: 65535 }), + SSL_CERT_FILE: Type.Optional(Type.String({ minimumLength: 1 })), + SSL_KEY_FILE: Type.Optional(Type.String({ minimumLength: 1 })), + ALLOWED_ORIGINS: Type.Optional(Type.String({ minimumLength: 1 })), + DATA_PATH: Type.String({ default: `${os.homedir()}/.slvctrlplus`, minimumLength: 1 }), +}); + +type Env = Static; + +export const parseEnv = (env: NodeJS.ProcessEnv): Env => { + const converted = Value.Default(EnvSchema, Value.Convert(EnvSchema, { ...env })); + + if (!Value.Check(EnvSchema, converted)) { + const errors = [...Value.Errors(EnvSchema, converted)]; + throw new Error(`Invalid environment variables:\n${errors.map(e => ` ${e.path}: ${e.message}`).join('\n')}`); + } + + const hasCertFile = converted.SSL_CERT_FILE !== undefined; + const hasKeyFile = converted.SSL_KEY_FILE !== undefined; + + if (hasCertFile !== hasKeyFile) { + throw new Error('Invalid environment variables:\n /SSL_CERT_FILE and /SSL_KEY_FILE must be set together'); + } + + return Value.Decode(EnvSchema, converted); +}; diff --git a/src/factory/serialPortFactory.ts b/src/factory/serialPortFactory.ts index d5fd376f..c4aa47ac 100644 --- a/src/factory/serialPortFactory.ts +++ b/src/factory/serialPortFactory.ts @@ -1,9 +1,11 @@ import { SerialPort, SerialPortOpenOptions } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; +import { BindingInterface } from '@serialport/bindings-interface'; export default class SerialPortFactory { - public create(options: SerialPortOpenOptions): SerialPort { + public create(options: SerialPortOpenOptions): SerialPortStream { return new SerialPort(options); } } diff --git a/src/health/healthMetricsCollector.ts b/src/health/healthMetricsCollector.ts new file mode 100644 index 00000000..aaa4236b --- /dev/null +++ b/src/health/healthMetricsCollector.ts @@ -0,0 +1,118 @@ +import os from 'os'; +import process from 'process'; +import { EventEmitter } from 'events'; +import { OSUtils } from 'node-os-utils'; +import { IntervalAsync, setIntervalAsync } from '../util/async.js'; +import Logger from '../logging/Logger.js'; +import { logError } from '../util/error.js'; +import { SerializedHealthMetrics } from './serializedTypes.js'; + +export enum HealthMetricsCollectorEvent { + collected = 'healthMetricsCollected', +} + +export default class HealthMetricsCollector +{ + private readonly osUtils: OSUtils; + + private readonly logger: Logger; + + private readonly eventEmitter: EventEmitter; + + private currentMetrics: SerializedHealthMetrics | null = null; + + private intervalHandle: IntervalAsync | null = null; + + public constructor(logger: Logger, eventEmitter: EventEmitter) + { + this.logger = logger; + this.eventEmitter = eventEmitter; + this.osUtils = new OSUtils({ + cacheEnabled: true, + cacheTTL: 60_000, + }); + } + + public start(intervalMs: number): void + { + if (this.intervalHandle !== null) { + return; + } + + this.intervalHandle = setIntervalAsync( + async () => await this.refresh(), + { intervalMs, timeoutMs: intervalMs * 3, onError: (err) => logError(this.logger, `Health metrics refresh failed`, err) }, + ); + } + + public stop(): void + { + this.intervalHandle?.clear(); + this.intervalHandle = null; + this.eventEmitter.removeAllListeners(); + } + + public on(event: HealthMetricsCollectorEvent, listener: (metrics: SerializedHealthMetrics) => void): this + { + this.eventEmitter.on(event, listener); + return this; + } + + public collect(): SerializedHealthMetrics | null + { + return this.currentMetrics; + } + + private async refresh(): Promise + { + const [cpuUsage, cpuInfo, cpuLoadAvg, memInfo, sysUptime, networkStats, networkInterfaces] = await Promise.all([ + this.osUtils.cpu.usage(), + this.osUtils.cpu.info(), + this.osUtils.cpu.loadAverage(), + this.osUtils.memory.info(), + this.osUtils.system.uptime(), + this.osUtils.network.statsAsync(), + this.osUtils.network.interfaces(), + ]); + + const metrics: SerializedHealthMetrics = { + process: { + memoryUsage: process.memoryUsage(), + }, + system: { + cpu: { + usage: true === cpuUsage.success ? cpuUsage.data : null, + average: true === cpuLoadAvg.success ? cpuLoadAvg.data.load1 : null, + cores: true === cpuInfo.success ? cpuInfo.data.cores : null, + model: true === cpuInfo.success ? cpuInfo.data.model : null, + }, + memory: true === memInfo.success ? { + totalMemMb: memInfo.data.total.toMB(), + usedMemMb: memInfo.data.used.toMB(), + freeMemMb: memInfo.data.available.toMB(), + usedMemPercentage: memInfo.data.usagePercentage, + freeMemPercentage: 100 - memInfo.data.usagePercentage, + } : null, + os: { + name: os.version(), + type: os.type(), + arch: os.arch(), + platform: os.platform(), + }, + network: { + netstat: true === networkStats.success ? networkStats.data : null, + }, + ip: true === networkInterfaces.success + ? (networkInterfaces.data + .find(i => i.internal === false && i.type !== 'loopback' && i.addresses.some(a => a.family === 'IPv4' && a.internal === false)) + ?.addresses.find(a => a.family === 'IPv4' && a.internal === false)?.address ?? null) + : null, + hostname: os.hostname(), + uptime: true === sysUptime.success ? Math.floor(sysUptime.data.uptime / 1000) : null, + } + }; + + this.currentMetrics = metrics; + this.eventEmitter.emit(HealthMetricsCollectorEvent.collected, metrics); + } +} diff --git a/src/health/serializedTypes.ts b/src/health/serializedTypes.ts new file mode 100644 index 00000000..45f3ed35 --- /dev/null +++ b/src/health/serializedTypes.ts @@ -0,0 +1,34 @@ +import { NetworkStats } from 'node-os-utils'; + +export type SerializedHealthMetrics = { + process: { + memoryUsage: NodeJS.MemoryUsage, + }, + system: { + cpu: { + usage: number | null, + average: number | null, + cores: number | null, + model: string | null, + }, + memory: { + totalMemMb: number; + usedMemMb: number; + freeMemMb: number; + usedMemPercentage: number; + freeMemPercentage: number; + } | null, + os: { + name: string, + type: string, + arch: string, + platform: string, + }, + network: { + netstat: NetworkStats[] | null, + }, + ip?: string | null, + hostname: string | null, + uptime: number | null, + }, +}; diff --git a/src/index.ts b/src/index.ts index 0b87c8cf..8b9defff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,163 +1,38 @@ import 'dotenv/config'; import 'reflect-metadata'; -import cors from 'cors'; -import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; -import express from 'express'; -import { Pimple } from '@timesplinter/pimple'; -import ControllerServiceProvider from './serviceProvider/controllerServiceProvider.js'; -import RepositoryServiceProvider from './serviceProvider/repositoryServiceProvider.js'; -import SerializationServiceProvider from './serviceProvider/serializationServiceProvider.js'; -import FactoryServiceProvider from './serviceProvider/factoryServiceProvider.js'; -import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; -import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; -import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; -import http from 'http' -import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; -import { DeviceUpdateData } from './socket/types.js'; -import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; -import Device from './device/device.js'; -import WebSocketEvent from './device/webSocketEvent.js'; -import ServerServiceProvider from './serviceProvider/serverServiceProvider.js'; -import AutomationEventType from './automation/automationEventType.js'; -import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; -import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; -import ServiceMap from './serviceMap.js'; -import SettingsEventType from './settings/settingsEventType.js'; -import type Settings from './settings/settings.js'; -import { executeController } from './util/expressUtils.js'; -import { DeviceManagerEvent } from './device/deviceManager.js'; +import { createApp, createContainer, SslConfig } from './app.js'; +import { parseEnv } from './env.js'; import { logError } from './util/error.js'; -const APP_PORT = process.env.PORT ?? '1337'; -const ALLOWED_ORIGINS = undefined !== process.env.ALLOWED_ORIGINS && null !== process.env.ALLOWED_ORIGINS.length - ? process.env.ALLOWED_ORIGINS.split(',') +const env = parseEnv(process.env); + +const allowedOrigins = undefined !== env.ALLOWED_ORIGINS && env.ALLOWED_ORIGINS.length > 0 + ? env.ALLOWED_ORIGINS.split(',') .map(origin => origin.trim()) .filter(origin => origin.length > 0) : []; -const container = new Pimple(); -const app = express(); -const httpServer = http.createServer(app); +const sslConfig: SslConfig | undefined = env.SSL_KEY_FILE !== undefined && env.SSL_CERT_FILE !== undefined + ? { port: env.HTTPS_PORT, keyFile: env.SSL_KEY_FILE, certFile: env.SSL_CERT_FILE } + : undefined; -container - .register(new LoggerServiceProvider()) - .register(new ServerServiceProvider(httpServer)) - .register(new SettingsServiceProvider()) - .register(new DeviceServiceProvider()) - .register(new ControllerServiceProvider()) - .register(new SocketServiceProvider()) - .register(new RepositoryServiceProvider()) - .register(new SerializationServiceProvider()) - .register(new AutomationServiceProvider()) - .register(new FactoryServiceProvider()) - .register(new SchemaValidationServiceProvider()) -; +const appOptions = { allowedOrigins, dataPath: env.DATA_PATH }; +const container = createContainer(env.DATA_PATH); +const app = createApp(container, appOptions); const logger = container.get('logger.default'); -const io = container.get('server.websocket'); -const deviceManager = container.get('device.manager'); -const serialPortObserver = container.get('device.observer.serial'); -const bleObserver = container.get('device.observer.ble'); -const settingsManager = container.get('settings.manager'); -const scriptRuntime = container.get('automation.scriptRuntime'); - -container.get('device.provider.loader') - .loadFromSettings() - .catch(e => logError(logger, `Loading device providers failed`, e)); - -// Middlewares -app - .use((req, res, next) => { - // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged - if (req.headers['access-control-request-private-network'] === 'true') { - res.header('Access-Control-Allow-Private-Network', 'true'); - } - - next(); - }) - .use(cors({ - origin: (origin, callback) => { - if (undefined === origin || ALLOWED_ORIGINS.length === 0) { - return callback(null, true); - } - - return callback(null, ALLOWED_ORIGINS.includes(origin)); - }, - })) - .use(contentTypeMiddleware) - .use(express.json()) - .use(express.text()) -; - -// Routes -app.get('/devices', executeController(container, 'controller.getDevices')); -app.get('/device/:deviceId', executeController(container, 'controller.getDevice')); -app.patch('/device/:deviceId', executeController(container, 'controller.patchDevice')); - -app.get('/automation/scripts', executeController(container, 'controller.automation.getScripts')); -app.get('/automation/scripts/:fileName', executeController(container, 'controller.automation.getScript')); - -app.post('/automation/scripts/:fileName', executeController(container, 'controller.automation.createScript')); -app.delete('/automation/scripts/:fileName', executeController(container, 'controller.automation.deleteScript')); - -app.get('/automation/log', executeController(container, 'controller.automation.getLog')); -app.post('/automation/run', executeController(container, 'controller.automation.runScript')); -app.get('/automation/stop', executeController(container, 'controller.automation.stopScript')); -app.get('/automation/status', executeController(container, 'controller.automation.statusScript')); - -app.get('/settings', executeController(container, 'controller.settings.get')); -app.put('/settings', executeController(container, 'controller.settings.put')); - -app.get('/health', executeController(container, 'controller.health')); -app.get('/version', executeController(container, 'controller.version')); - -// Whenever someone connects this gets executed -io.on('connection', socket => { - logger.debug(`Client connected: ${socket.id}`); - - socket.on('disconnect', () => { - logger.debug(`Client disconnected: ${socket.id}`); - }); - - const deviceUpdateHandler = container.get('socket.deviceUpdateHandler'); - - socket.on(WebSocketEvent.deviceUpdateReceived, (data: DeviceUpdateData) => deviceUpdateHandler.handle(data)); -}); -const serializer = container.get('serializer.classToPlain'); - -const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); - -void serialPortObserver.init().catch(e => logError(logger, 'Initializing serial port observer failed', e)); -void bleObserver.init().catch(e => logError(logger, 'Initializing BLE observer failed', e)); - -deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { - io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); - scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); -}); - -deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { - io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); - scriptRuntime.runForEvent(DeviceManagerEvent.deviceDisconnected, device); -}); - -deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { - io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); - scriptRuntime.runForEvent(DeviceManagerEvent.deviceRefreshed, device); -}); - -settingsManager.on(SettingsEventType.changed, (settings: Settings) => { - io.emit(SettingsEventType.changed, serializer.transform(settings)); +process.on('uncaughtException', (error: Error) => { + logger.error('Asynchronous error caught', error); }); -// Automation events -scriptRuntime.on(AutomationEventType.consoleLog, (data: any) => io.emit(AutomationEventType.consoleLog, data)); +app.serve(env.PORT, sslConfig); -httpServer.listen(APP_PORT, () => { - logger.info(`Node version: ${process.version}`); - logger.info(`SlvCtrl+ server listening on port ${APP_PORT}!`); -}); +const shutdown = (): void => { + app.shutdown() + .catch((err: unknown) => logError(logger, 'Error during shutdown', err)) + .finally(() => process.exit(0)); +}; -process.on('uncaughtException', (error: Error) => { - logger.error('Asynchronous error caught', error); -}); +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/src/repository/automationScriptRepository.ts b/src/repository/automationScriptRepository.ts index 8a399e1c..8cff2c04 100644 --- a/src/repository/automationScriptRepository.ts +++ b/src/repository/automationScriptRepository.ts @@ -1,4 +1,6 @@ +import path from 'path'; import AutomationScript from '../entity/automationScript.js'; +import { hasProperty } from '../util/objects.js'; import AutomationScriptRepositoryInterface from './automationScriptRepositoryInterface.js'; import fs from 'fs'; @@ -27,9 +29,9 @@ export default class AutomationScriptRepository implements AutomationScriptRepos public getByName(name: string): string|null { try { - return fs.readFileSync(`${this.location}${name}`).toString(); + return fs.readFileSync(this.resolveScriptPath(name), 'utf8'); } catch (e: unknown) { - if (typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT') { + if (hasProperty(e, 'code') && e.code === 'ENOENT') { return null; } @@ -39,11 +41,21 @@ export default class AutomationScriptRepository implements AutomationScriptRepos public save(fileName: string, data: string): void { - fs.writeFileSync(`${this.location}${fileName}`, data); + fs.writeFileSync(this.resolveScriptPath(fileName), data); } public delete(fileName: string): void { - fs.unlinkSync(`${this.location}${fileName}`); + fs.unlinkSync(this.resolveScriptPath(fileName)); + } + + private resolveScriptPath(fileName: string): string + { + const resolved = path.resolve(this.location, fileName); + const relative = path.relative(this.location, resolved); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Invalid script path: ${fileName}`); + } + return resolved; } } diff --git a/src/repository/connectedDeviceRepository.ts b/src/repository/connectedDeviceRepository.ts index 6d59faed..5ad090fe 100644 --- a/src/repository/connectedDeviceRepository.ts +++ b/src/repository/connectedDeviceRepository.ts @@ -1,6 +1,7 @@ import DeviceRepositoryInterface from './deviceRepositoryInterface.js'; import DeviceManager from '../device/deviceManager.js'; import Device from '../device/device.js'; +import { DeviceId } from '../device/deviceId.js'; export default class ConnectedDeviceRepository implements DeviceRepositoryInterface { @@ -15,7 +16,7 @@ export default class ConnectedDeviceRepository implements DeviceRepositoryInterf return this.deviceManager.getConnectedDevices(); } - public getById(uuid: string): Device | null { + public getById(uuid: DeviceId): Device | null { return this.deviceManager.getConnectedDevice(uuid); } } diff --git a/src/serial/synchronousSerialPort.ts b/src/serial/synchronousSerialPort.ts index 0f56d6f7..39616b81 100644 --- a/src/serial/synchronousSerialPort.ts +++ b/src/serial/synchronousSerialPort.ts @@ -17,6 +17,8 @@ export default class SynchronousSerialPort private readonly logger: Logger; + private closed = false; + public constructor(portInfo: PortInfo, reader: Readable, writer: Writable, logger: Logger) { this.portInfo = portInfo; this.reader = reader; @@ -43,6 +45,7 @@ export default class SynchronousSerialPort ); const handleClose = (): void => { + this.closed = true; this.queue.cancel(); // Prevent second call and clean up listeners this.writer.off('close', handleClose); @@ -55,10 +58,11 @@ export default class SynchronousSerialPort } public isOpen(): boolean { - return this.writer.writable && this.reader.readable; + return !this.closed && this.writer.writable && this.reader.readable; } public close(): void { + this.closed = true; this.queue.cancel(); this.writer.end(() => { this.writer.destroy(); diff --git a/src/serialization/classToPlainSerializer.ts b/src/serialization/classToPlainSerializer.ts index e3087578..9e0895e8 100644 --- a/src/serialization/classToPlainSerializer.ts +++ b/src/serialization/classToPlainSerializer.ts @@ -1,5 +1,4 @@ import { instanceToPlain, ClassTransformOptions, TypeOptions } from 'class-transformer'; -import { JsonValue } from '../types.js'; export default class ClassToPlainSerializer { @@ -10,7 +9,8 @@ export default class ClassToPlainSerializer this.options = options; } - public transform(object: T, typeOptions: TypeOptions|null = null): JsonValue + public transform>(object: object, typeOptions?: TypeOptions | null): TOut; + public transform(object: object, typeOptions: TypeOptions | null = null): Record { const result = instanceToPlain(object, this.options); diff --git a/src/serviceMap.ts b/src/serviceMap.ts index bafc5d1f..e27d557c 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -1,5 +1,4 @@ import { Ajv } from 'ajv'; -import { Server } from 'socket.io'; import ClassToPlainSerializer from './serialization/classToPlainSerializer.js'; import PlainToClassSerializer from './serialization/plainToClassSerializer.js'; import Logger from './logging/Logger.js'; @@ -17,9 +16,10 @@ import SettingsManager from './settings/settingsManager.js'; import ScriptRuntime from './automation/scriptRuntime.js'; import ConnectedDeviceRepository from './repository/connectedDeviceRepository.js'; import AutomationScriptRepository from './repository/automationScriptRepository.js'; -import DeviceProviderLoader from './device/provider/deviceProviderLoader.js'; +import DeviceProviderManager from './device/provider/deviceProviderManager.js'; import DeviceUpdateHandler from './socket/deviceUpdateHandler.js'; import HealthController from './controller/healthController.js'; +import HealthMetricsCollector from './health/healthMetricsCollector.js'; import GetDevicesController from './controller/getDevicesController.js'; import GetDeviceController from './controller/getDeviceController.js'; import PatchDeviceController from './controller/patchDeviceController.js'; @@ -60,9 +60,6 @@ type ServiceMap = { /* loggerServiceProvider */ 'logger.default': Logger, - /* serverServiceProvider */ - 'server.websocket': Server, - /* deviceServiceProvider */ 'device.manager': DeviceManager, 'device.serial.transport.factory': SerialDeviceTransportFactory, @@ -105,7 +102,7 @@ type ServiceMap = { 'repository.connectedDevices': ConnectedDeviceRepository, 'repository.automationScript': AutomationScriptRepository, - 'device.provider.loader': DeviceProviderLoader, + 'device.provider.loader': DeviceProviderManager, 'socket.deviceUpdateHandler': DeviceUpdateHandler, /* controllerServiceProvider */ @@ -124,6 +121,9 @@ type ServiceMap = { 'controller.settings.get': GetSettingsController, 'controller.settings.put': PutSettingsController, 'controller.version': VersionController, + + /* healthServiceProvider */ + 'health.metricsCollector': HealthMetricsCollector, } export default ServiceMap; diff --git a/src/serviceProvider/automationServiceProvider.ts b/src/serviceProvider/automationServiceProvider.ts index ea26ae05..7ba75f15 100644 --- a/src/serviceProvider/automationServiceProvider.ts +++ b/src/serviceProvider/automationServiceProvider.ts @@ -1,23 +1,29 @@ import { Pimple, ServiceProvider } from '@timesplinter/pimple'; import ScriptRuntime from '../automation/scriptRuntime.js'; -import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; export default class AutomationServiceProvider implements ServiceProvider { + private readonly dataPath: string | undefined; + + public constructor(dataPath: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('automation.scriptRuntime', () => { - const logPath = `${os.homedir()}/.slvctrlplus/`; + const logPath = `${this.dataPath}/automation-logs`; if (false === fs.existsSync(logPath)) { - fs.mkdirSync(logPath); + fs.mkdirSync(logPath, { recursive: true }); } return new ScriptRuntime( container.get('repository.connectedDevices'), logPath, container.get('factory.eventEmitter').create(), + container.get('logger.default'), ); }); } diff --git a/src/serviceProvider/controllerServiceProvider.ts b/src/serviceProvider/controllerServiceProvider.ts index 4551aef4..092aa3a4 100644 --- a/src/serviceProvider/controllerServiceProvider.ts +++ b/src/serviceProvider/controllerServiceProvider.ts @@ -20,7 +20,7 @@ export default class ControllerServiceProvider implements ServiceProvider): void { container.set('controller.health', () => { - return new HealthController(); + return new HealthController(container.get('health.metricsCollector')); }); container.set('controller.getDevices', () => { diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index fd42763c..ffe303ac 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -8,7 +8,7 @@ import BufferedDeviceUpdater from '../device/updater/bufferedDeviceUpdater.js'; import GenericDeviceUpdater from '../device/genericDeviceUpdater.js'; import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransportFactory.js'; import Device from '../device/device.js'; -import DeviceProviderLoader from '../device/provider/deviceProviderLoader.js'; +import DeviceProviderManager from '../device/provider/deviceProviderManager.js'; import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import ButtplugIoWebsocketDeviceProvider from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import ButtplugIoWebsocketDeviceProviderFactory @@ -39,6 +39,7 @@ import Estim2bDeviceFactory from '../device/protocol/estim2b/estim2bDeviceFactor import BleObserver from '../device/transport/bleObserver.js'; import AiroticDeviceProvider from '../device/protocol/airotic/airoticDeviceProvider.js'; import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; +import { DeviceId } from '../device/deviceId.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -73,7 +74,7 @@ export default class DeviceServiceProvider implements ServiceProvider { return new DeviceManager( container.get('factory.eventEmitter').create(), - new Map(), + new Map(), container.get('logger.default') ); }); @@ -168,9 +169,8 @@ export default class DeviceServiceProvider implements ServiceProvider { - return new DeviceProviderLoader( - container.get('settings'), + container.set('device.provider.loader', (): DeviceProviderManager => { + return new DeviceProviderManager( new Map>([ [ SlvCtrlPlusSerialDeviceProvider.providerName, diff --git a/src/serviceProvider/healthServiceProvider.ts b/src/serviceProvider/healthServiceProvider.ts new file mode 100644 index 00000000..7140892f --- /dev/null +++ b/src/serviceProvider/healthServiceProvider.ts @@ -0,0 +1,13 @@ +import { Pimple, ServiceProvider } from '@timesplinter/pimple'; +import ServiceMap from '../serviceMap.js'; +import HealthMetricsCollector from '../health/healthMetricsCollector.js'; + +export default class HealthServiceProvider implements ServiceProvider +{ + public register(container: Pimple): void { + container.set('health.metricsCollector', () => new HealthMetricsCollector( + container.get('logger.default'), + container.get('factory.eventEmitter').create(), + )); + } +} diff --git a/src/serviceProvider/repositoryServiceProvider.ts b/src/serviceProvider/repositoryServiceProvider.ts index b0092c17..e29fd9bf 100644 --- a/src/serviceProvider/repositoryServiceProvider.ts +++ b/src/serviceProvider/repositoryServiceProvider.ts @@ -1,12 +1,17 @@ import { Pimple, ServiceProvider } from '@timesplinter/pimple'; import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.js'; import AutomationScriptRepository from '../repository/automationScriptRepository.js'; -import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; export default class RepositoryServiceProvider implements ServiceProvider { + private readonly dataPath: string | undefined; + + public constructor(dataPath?: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('repository.connectedDevices', () => { return new ConnectedDeviceRepository( @@ -15,10 +20,10 @@ export default class RepositoryServiceProvider implements ServiceProvider { - const scriptsPath = `${os.homedir()}/.slvctrlplus/automation-scripts/`; + const scriptsPath = `${this.dataPath}/automation-scripts`; if (false === fs.existsSync(scriptsPath)) { - fs.mkdirSync(scriptsPath); + fs.mkdirSync(scriptsPath, { recursive: true }); } return new AutomationScriptRepository(scriptsPath); diff --git a/src/serviceProvider/serverServiceProvider.ts b/src/serviceProvider/serverServiceProvider.ts deleted file mode 100644 index 97061133..00000000 --- a/src/serviceProvider/serverServiceProvider.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Pimple, ServiceProvider } from '@timesplinter/pimple'; -import http from 'http' -import { Server } from 'socket.io'; -import ServiceMap from '../serviceMap.js'; - -export default class ServerServiceProvider implements ServiceProvider -{ - private readonly httpServer: http.Server; - - public constructor(server: http.Server) { - this.httpServer = server; - } - - public register(container: Pimple): void { - container.set('server.websocket', () => { - return new Server(this.httpServer, { - cors: { - origin: '*', - methods: ['GET', 'POST', 'PATCH'] - } - }); - }); - } -} diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index d3a32509..836e97c1 100644 --- a/src/serviceProvider/settingsServiceProvider.ts +++ b/src/serviceProvider/settingsServiceProvider.ts @@ -7,6 +7,12 @@ import { SettingsSchema } from '../settings/settings.js'; export default class SettingsServiceProvider implements ServiceProvider { + private readonly dataPath: string | undefined; + + public constructor(dataPath?: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('settings.schema.validator', () => { const jsonSchemaValidatorFactory = container.get('factory.validator.schema.json'); @@ -15,14 +21,16 @@ export default class SettingsServiceProvider implements ServiceProvider { - const settingsPath = `${os.homedir()}/.slvctrlplus/`; + const dataPath = this.dataPath ?? `${os.homedir()}/.slvctrlplus`; - if (false === fs.existsSync(settingsPath)) { - fs.mkdirSync(settingsPath); + if (false === fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath, { recursive: true }); } + const settingsFilePath = `${dataPath}/settings.json`; + const settingsManager = new SettingsManager( - `${settingsPath}settings.json`, + settingsFilePath, container.get('serializer.plainToClass'), container.get('serializer.classToPlain'), container.get('settings.schema.validator'), diff --git a/src/settings/serializedTypes.ts b/src/settings/serializedTypes.ts new file mode 100644 index 00000000..fb9924e0 --- /dev/null +++ b/src/settings/serializedTypes.ts @@ -0,0 +1,21 @@ +import { JsonObject } from '../types.js'; + +export type SerializedKnownDevice = { + id: string; + serialNo: string; + name: string; + type: string; + source: string; + config: JsonObject; +} + +export type SerializedDeviceSource = { + id: string; + type: string; + config: JsonObject; +} + +export type SerializedSettings = { + knownDevices: Record; + deviceSources: Record; +} diff --git a/src/socket/deviceUpdateHandler.ts b/src/socket/deviceUpdateHandler.ts index ba316983..0bdc760d 100644 --- a/src/socket/deviceUpdateHandler.ts +++ b/src/socket/deviceUpdateHandler.ts @@ -3,6 +3,7 @@ import DeviceUpdaterInterface from '../device/updater/deviceUpdaterInterface.js' import { DeviceUpdateData } from './types.js'; import Logger from '../logging/Logger.js'; import { logError } from '../util/error.js'; +import { DeviceId } from '../device/deviceId.js'; export default class DeviceUpdateHandler { @@ -24,7 +25,7 @@ export default class DeviceUpdateHandler public async handle(data: DeviceUpdateData): Promise { const deviceId = data.deviceId; - const device = this.connectedDeviceRepository.getById(deviceId); + const device = this.connectedDeviceRepository.getById(DeviceId.create(deviceId)); if (null === device) { return; diff --git a/src/socket/types.ts b/src/socket/types.ts index 3c369729..eabc32bc 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -1,3 +1,25 @@ +import { Server } from 'socket.io'; import { DeviceData } from '../device/device.js'; +import WebSocketEvent from '../device/webSocketEvent.js'; +import SettingsEventType from '../settings/settingsEventType.js'; +import AutomationEventType from '../automation/automationEventType.js'; +import { SerializedHealthMetrics } from '../health/serializedTypes.js'; +import { SerializedDevice } from '../device/serializedTypes.js'; +import { SerializedSettings } from '../settings/serializedTypes.js'; export type DeviceUpdateData = { deviceId: string, data: DeviceData } + +export interface ClientToServerEvents { + [WebSocketEvent.deviceUpdateReceived]: (data: DeviceUpdateData) => void; +} + +export interface ServerToClientEvents { + [WebSocketEvent.deviceConnected]: (data: SerializedDevice) => void; + [WebSocketEvent.deviceDisconnected]: (data: SerializedDevice) => void; + [WebSocketEvent.deviceRefreshed]: (data: SerializedDevice) => void; + [WebSocketEvent.healthMetrics]: (data: SerializedHealthMetrics) => void; + [SettingsEventType.changed]: (data: SerializedSettings) => void; + [AutomationEventType.consoleLog]: (data: string) => void; +} + +export type WebsocketServer = Server; diff --git a/src/util/async.ts b/src/util/async.ts index 8e00d6dd..f9985b7f 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -1,5 +1,12 @@ export const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); +export class IntervalTimeoutError extends Error { + public constructor(timeoutMs: number) { + super(`Interval function timed out (>${timeoutMs}ms)`); + this.name = 'IntervalTimeoutError'; + } +} + export const setImmediateInterval = ( callback: (...args: TArgs) => void, delay?: number, @@ -41,10 +48,11 @@ export const setIntervalAsync = ( let timeoutHandle: ReturnType | undefined; if (undefined !== options.timeoutMs) { + const timeoutMs = options.timeoutMs; promises.push(new Promise((_, reject) => timeoutHandle = setTimeout(() => { - reject(new Error(`Interval function timed out (>${options.timeoutMs}ms)`)); - }, options.timeoutMs)) + reject(new IntervalTimeoutError(timeoutMs)); + }, timeoutMs)) ); } diff --git a/src/util/error.ts b/src/util/error.ts index b2f53a12..3867aadb 100644 --- a/src/util/error.ts +++ b/src/util/error.ts @@ -1,7 +1,13 @@ import BaseError from 'modern-errors'; import Logger from '../logging/Logger.js'; +import { IntervalTimeoutError } from './async.js'; export const logError = (logger: Logger, message: string, error: unknown): void => { - const baseError = BaseError.normalize(error); - logger.error(`${message}: ${baseError.message}`, error); + if (error instanceof IntervalTimeoutError) { + logger.warn(`${message}: ${error.message}`); + return; + } + + const baseError = error instanceof Error ? error : BaseError.normalize(error); + logger.error(`${message}: ${baseError.message}`, baseError); }; diff --git a/src/util/expressUtils.ts b/src/util/expressUtils.ts index 782e73ab..927f89d1 100644 --- a/src/util/expressUtils.ts +++ b/src/util/expressUtils.ts @@ -1,5 +1,5 @@ import type { Request, Response } from 'express'; -import type { Pimple } from '@timesplinter/pimple'; +import type { Container } from '@timesplinter/pimple'; import type ServiceMap from '../serviceMap.js'; import ControllerInterface from '../controller/controllerInterface.js'; @@ -8,7 +8,7 @@ export type ControllerKey = { }[keyof ServiceMap]; export const executeController = ( - container: Pimple, + container: Container, controllerName: K ): (req: Request, res: Response) => void | Promise => { return (req: Request, res: Response) => { diff --git a/src/util/objects.ts b/src/util/objects.ts index 999bbf15..8ea7effc 100644 --- a/src/util/objects.ts +++ b/src/util/objects.ts @@ -4,3 +4,7 @@ export const getTypedKeys = (obj: T): (StringKey)[] => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return Object.keys(obj) as StringKey[]; } + +export const hasProperty = (obj: unknown, property: K): obj is Record => { + return typeof obj === 'object' && obj !== null && property in obj; +} diff --git a/tests/integration/api.spec.ts b/tests/integration/api.spec.ts new file mode 100644 index 00000000..f7a21f9e --- /dev/null +++ b/tests/integration/api.spec.ts @@ -0,0 +1,284 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { TEST_DEVICE_ID, createTestApp, teardownTestApp, resetTestApp, connectDevices, TestApp } from './helpers/appHelper.js'; + +describe('REST API', () => { + let app: TestApp; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await teardownTestApp(app); + }); + + beforeEach(async () => { + await resetTestApp(app); + }); + + describe('GET /version', () => { + it('returns version string', async () => { + const res = await request(app.httpServer).get('/version'); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('version'); + expect(typeof res.body.version).toBe('string'); + }); + }); + + describe('GET /health', () => { + it('returns 200 with metrics or 204 when not yet collected', async () => { + const res = await request(app.httpServer).get('/health'); + + expect(res.status).toBeOneOf([200, 204]); + }); + }); + + describe('GET /devices', () => { + it('returns connected devices', async () => { + const testDeviceName = 'Test Random Generator'; + + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: testDeviceName }]); + + const res = await request(app.httpServer).get('/devices'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ + deviceId: TEST_DEVICE_ID, + deviceName: testDeviceName, + provider: 'virtual', + deviceModel: 'randomGenerator', + fwVersion: '1.0.0', + state: 'READY', + type: 'virtual', + controllable: false, + config: { min: 0, max: 100 }, + attributes: { + value: expect.objectContaining({ + name: 'value', + label: 'Random number', + modifier: 'ro', + type: 'int', + }), + }, + })); + }); + + it('returns empty list when no devices connected', async () => { + const res = await request(app.httpServer).get('/devices'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + expect(res.body.items).toEqual([]); + }); + }); + + describe('GET /device/:deviceId', () => { + it('returns a single connected device', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const res = await request(app.httpServer).get(`/device/${TEST_DEVICE_ID}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(expect.objectContaining({ + deviceId: TEST_DEVICE_ID, + deviceName: 'Test Random Generator', + })); + }); + + it('returns 404 for unknown device', async () => { + const res = await request(app.httpServer).get('/device/unknown-id'); + + expect(res.status).toBe(404); + }); + }); + + describe('PATCH /device/:deviceId', () => { + it('returns 404 for unknown device', async () => { + const res = await request(app.httpServer) + .patch('/device/unknown-id') + .send({}); + + expect(res.status).toBe(404); + }); + + it('returns 202 for connected device', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const res = await request(app.httpServer) + .patch(`/device/${TEST_DEVICE_ID}`) + .send({}); + + expect(res.status).toBe(202); + }); + }); + + describe('GET /settings', () => { + it('returns current settings as JSON', async () => { + const res = await request(app.httpServer).get('/settings'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.body).toHaveProperty('knownDevices'); + expect(res.body).toHaveProperty('deviceSources'); + }); + }); + + describe('PUT /settings', () => { + it('accepts valid settings and returns them', async () => { + const getRes = await request(app.httpServer).get('/settings'); + const currentSettings = getRes.body; + + const res = await request(app.httpServer) + .put('/settings') + .send(currentSettings); + + expect(res.status).toBe(200); + const body = JSON.parse(res.text); + expect(body).toHaveProperty('knownDevices'); + expect(body).toHaveProperty('deviceSources'); + }); + + it('returns 400 for invalid settings', async () => { + const res = await request(app.httpServer) + .put('/settings') + .send({ invalid: true }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('errors'); + }); + }); + + describe('GET /automation/scripts', () => { + it('returns empty list when no scripts exist', async () => { + const res = await request(app.httpServer).get('/automation/scripts'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + expect(res.body.items).toEqual([]); + }); + + it('lists saved scripts', async () => { + await request(app.httpServer) + .post('/automation/scripts/test.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.httpServer).get('/automation/scripts'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ fileName: 'test.js' })); + }); + }); + + describe('POST /automation/scripts/:fileName', () => { + it('creates a script and returns its content', async () => { + const script = 'onEvent(() => {});'; + + const res = await request(app.httpServer) + .post('/automation/scripts/my-script.js') + .set('Content-Type', 'text/plain') + .send(script); + + expect(res.status).toBe(201); + expect(res.text).toBe(script); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(app.httpServer) + .post('/automation/scripts/INVALID_NAME.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + expect(res.status).toBe(400); + }); + + it('returns 400 for wrong content type', async () => { + const res = await request(app.httpServer) + .post('/automation/scripts/test.js') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ script: 'onEvent(() => {});' })); + + expect(res.status).toBe(400); + }); + }); + + describe('GET /automation/scripts/:fileName', () => { + it('returns script content as plain text', async () => { + const script = 'onEvent(() => {});'; + + await request(app.httpServer) + .post('/automation/scripts/read-test.js') + .set('Content-Type', 'text/plain') + .send(script); + + const res = await request(app.httpServer).get('/automation/scripts/read-test.js'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain/); + expect(res.text).toBe(script); + }); + + it('returns 404 for non-existent script', async () => { + const res = await request(app.httpServer).get('/automation/scripts/nonexistent.js'); + + expect(res.status).toBe(404); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(app.httpServer).get('/automation/scripts/INVALID_NAME.js'); + + expect(res.status).toBe(400); + }); + }); + + describe('DELETE /automation/scripts/:fileName', () => { + it('deletes a script and returns 204', async () => { + await request(app.httpServer) + .post('/automation/scripts/to-delete.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.httpServer).delete('/automation/scripts/to-delete.js'); + + expect(res.status).toBe(204); + + const getRes = await request(app.httpServer).get('/automation/scripts/to-delete.js'); + expect(getRes.status).toBe(404); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(app.httpServer).delete('/automation/scripts/INVALID_NAME.js'); + + expect(res.status).toBe(400); + }); + }); + + describe('GET /automation/log', () => { + it('returns log as plain text after a script has run', async () => { + await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.httpServer).get('/automation/log'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain/); + }); + }); + + describe('GET /automation/status', () => { + it('returns not running when no script is loaded', async () => { + const res = await request(app.httpServer).get('/automation/status'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(false); + expect(res.body.runningSince).toBeNull(); + }); + }); +}); diff --git a/tests/integration/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts new file mode 100644 index 00000000..0a46bef5 --- /dev/null +++ b/tests/integration/automationScripts.spec.ts @@ -0,0 +1,232 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import ScriptRuntime from '../../src/automation/scriptRuntime.js'; +import AutomationEventType from '../../src/automation/automationEventType.js'; +import { + TEST_DEVICE_ID, + createTestApp, + teardownTestApp, + resetTestApp, + connectDevices, + TestApp, +} from './helpers/appHelper.js'; + +const collectUntilMarker = ( + scriptRuntime: ScriptRuntime, + marker: string, + timeoutMs = 2000, +): Promise => { + return new Promise((resolve, reject) => { + const collected: string[] = []; + + const listener = (msg: string) => { + collected.push(msg); + if (msg === marker) { + scriptRuntime.off(AutomationEventType.consoleLog, listener); + clearTimeout(timeout); + resolve(collected); + } + }; + + const timeout = setTimeout(() => { + scriptRuntime.off(AutomationEventType.consoleLog, listener); + reject(new Error(`Timed out waiting for marker "${marker}". Got: ${JSON.stringify(collected)}`)); + }, timeoutMs); + + scriptRuntime.on(AutomationEventType.consoleLog, listener); + }); +} + +const waitForEvent = (scriptRuntime: ScriptRuntime, eventType: AutomationEventType): Promise => { + return new Promise((resolve, reject) => { + const listener = () => { + clearTimeout(timeout); + scriptRuntime.off(eventType, listener); + resolve(); + }; + const timeout = setTimeout(() => { + scriptRuntime.off(eventType, listener); + reject(new Error(`Timed out waiting for ${eventType}`)); + }, 2000); + scriptRuntime.on(eventType, listener); + }) +}; + +describe('Automation scripts', () => { + let app: TestApp; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await teardownTestApp(app); + }); + + beforeEach(async () => { + await resetTestApp(app); + }, 2000); + + describe('Script lifecycle via REST API', () => { + it('POST /automation/run loads the script and reports running status', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + + const scriptStarted = waitForEvent(scriptRuntime, AutomationEventType.scriptStarted); + + const res = await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(true); + expect(res.body.runningSince).toBeDefined(); + + await scriptStarted; + + const statusRes = await request(app.httpServer).get('/automation/status'); + expect(statusRes.status).toBe(200); + expect(statusRes.body.running).toBe(true); + }); + + it('POST /automation/run returns 400 for non-text/plain content type', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + + const res = await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ script: 'onEvent(() => {});' })); + + expect(res.status).toBe(400); + + await scriptRuntime.stop(); + }); + + it('GET /automation/stop stops the running script and emits scriptStopped', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + + await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const scriptStopped = waitForEvent(scriptRuntime, AutomationEventType.scriptStopped); + + const stopRes = await request(app.httpServer).get('/automation/stop'); + expect(stopRes.status).toBe(200); + + await scriptStopped; + + const statusRes = await request(app.httpServer).get('/automation/status'); + expect(statusRes.status).toBe(200); + expect(statusRes.body.running).toBe(false); + }); + + it('GET /automation/status returns not running when no script is loaded', async () => { + const res = await request(app.httpServer).get('/automation/status'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(false); + expect(res.body.runningSince).toBeNull(); + }); + }); + + describe('Script reacts to device events', () => { + it('onEvent is called with deviceConnected when a real device connects', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const MARKER = 'connect-done'; + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceConnected') return; + console.log(event.device.getDeviceId); + console.log(event.device.getDeviceName); + console.log('${MARKER}'); + }); + `); + + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const logs = await logsPromise; + + await scriptRuntime.stop(); + + expect(logs).toContain(TEST_DEVICE_ID); + expect(logs).toContain('Test Random Generator'); + }); + + it('onEvent receives the real attribute value on deviceRefreshed', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const MARKER = 'refresh-done'; + + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceRefreshed') return; + const attr = await event.device.getAttribute('value'); + console.log(attr !== undefined ? String(attr.value) : 'no-value'); + console.log('${MARKER}'); + }); + `); + + // collectUntilMarker is called after load() so its timeout only counts + // event-wait time, not isolate-creation/compilation time. + const logs = await collectUntilMarker(scriptRuntime, MARKER, 3000); + + await scriptRuntime.stop(); + + const valueLog = logs.find(l => l !== MARKER)!; + expect(valueLog).not.toBe('no-value'); + expect(Number(valueLog)).toBeGreaterThanOrEqual(0); + expect(Number(valueLog)).toBeLessThanOrEqual(100); + }); + + it('onEvent is called with deviceDisconnected when a real device disconnects', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const MARKER = 'disconnect-done'; + + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceDisconnected') return; + console.log(event.device.getDeviceId); + console.log('${MARKER}'); + }); + `); + + const device = app.container.get('device.manager').getConnectedDevices()[0]; + await device.close(); + + const logs = await logsPromise; + expect(logs).toContain(TEST_DEVICE_ID); + }); + + it('onStart runs once when script is loaded via API, before any device events', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const MARKER = 'start-done'; + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send(` + onStart(async () => { + console.log('init'); + console.log('${MARKER}'); + }); + onEvent(() => {}); + `); + + const logs = await logsPromise; + expect(logs).toContain('init'); + expect(logs.indexOf('init')).toBeLessThan(logs.indexOf(MARKER)); + }); + }); +}); diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts new file mode 100644 index 00000000..7943a36c --- /dev/null +++ b/tests/integration/deviceEvents.spec.ts @@ -0,0 +1,145 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; +import Device from '../../src/device/device.js'; +import Settings from '../../src/settings/settings.js'; +import KnownDevice from '../../src/settings/knownDevice.js'; +import DeviceSource from '../../src/settings/deviceSource.js'; +import RandomGeneratorVirtualDeviceLogic from '../../src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.js'; +import VirtualDevice from '../../src/device/protocol/virtual/virtualDevice.js'; +import { + TEST_DEVICE_ID, + TEST_SOURCE_ID, + NEW_DEVICE_ID, + createTestApp, + teardownTestApp, + resetTestApp, + connectDevices, + TestApp, +} from './helpers/appHelper.js'; + +describe('Device events', () => { + let app: TestApp; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await teardownTestApp(app); + }); + + beforeEach(async () => { + await resetTestApp(app); + }); + + it('virtual device connected', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const devices = app.container.get('device.manager').getConnectedDevices(); + + expect(devices).toHaveLength(1); + expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); + }); + + it('virtual device gets refreshed', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0]; + + let observedValue: number | undefined; + let changedValue: number | undefined; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceRefreshed, listener); + reject(new Error('Timed out waiting for device value to change')); + }, 1000); + + const listener = async () => { + const value = (await device.getAttribute('value'))?.value; + + if (undefined === observedValue) { + observedValue = value; + return; + } + + if (value !== observedValue) { + changedValue = value; + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceRefreshed, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceRefreshed, listener); + }); + + expect(observedValue).toBeDefined(); + expect(changedValue).toBeDefined(); + }); + + it('dynamically detects a new virtual device added to settings', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + await connectDevices(app.container, [{ id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }]); + + const devices = deviceManager.getConnectedDevices(); + + expect(devices).toHaveLength(2); + const actualDeviceIds = new Set(devices.map(d => d.getDeviceId)); + expect(actualDeviceIds).toEqual(new Set([TEST_DEVICE_ID, NEW_DEVICE_ID])); + }, 1000); + + it('dynamically removes a virtual device deleted from settings', async () => { + await connectDevices(app.container, [ + { id: TEST_DEVICE_ID, name: 'Test Random Generator' }, + { id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }, + ]); + + const deviceManager = app.container.get('device.manager'); + const settingsManager = app.container.get('settings.manager'); + + const deviceDisconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); + + const listener = (device: Device) => { + if (device.getDeviceId === NEW_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + const settingsWithOneDevice = new Settings(); + settingsWithOneDevice.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); + settingsWithOneDevice.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); + settingsManager.replace(settingsWithOneDevice); + + await deviceDisconnected; + }, 1000); + + it('virtual device disconnected', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0]; + + const disconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device disconnection')), 2000); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, () => { + clearTimeout(timeout); + resolve(); + }); + }); + + await device.close(); + await disconnected; + }); +}); diff --git a/tests/integration/devices/buttplugIoDevice.spec.ts b/tests/integration/devices/buttplugIoDevice.spec.ts new file mode 100644 index 00000000..5b6c7312 --- /dev/null +++ b/tests/integration/devices/buttplugIoDevice.spec.ts @@ -0,0 +1,195 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { DeviceAttributeModifier } from '../../../src/device/attribute/deviceAttribute.js'; +import { ButtplugIoServerSimulator } from '../helpers/buttplugIoServerSimulator.js'; +import { ActuatorType, SensorType } from 'buttplug'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; +import ButtplugIoWebsocketDeviceProvider from '../../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; + +const BUTTPLUG_SOURCE_ID = 'd5e6f7a8-5678-4321-abcd-ef1234567894'; + +function makeButtplugSettings(port: number): object { + return { + knownDevices: {}, + deviceSources: { + [BUTTPLUG_SOURCE_ID]: { + id: BUTTPLUG_SOURCE_ID, + type: ButtplugIoWebsocketDeviceProvider.providerName, + config: { + address: `127.0.0.1:${port}`, + autoScan: false, + useDeviceNameAsId: true, + }, + }, + }, + }; +} + +describe('Buttplug.io device lifecycle', () => { + let app: TestApp; + let simulator: ButtplugIoServerSimulator; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + simulator = new ButtplugIoServerSimulator(); + const simulatorPort = await simulator.start(); + + app = await createTestApp(makeButtplugSettings(simulatorPort)); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + await simulator.waitForClientReady(); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + await simulator.stop(); + }); + + beforeEach(async() => { + simulator.removeAllDevices(); + wsEmitSpy.mockClear(); + }); + + it('new device gets detected', async () => { + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + simulator.addDevice({ + name: 'MockDevice', + actuators: [ + { featureDescriptor: 'Vibrator', actuatorType: ActuatorType.Vibrate, stepCount: 20 }, + { featureDescriptor: 'Switch', actuatorType: ActuatorType.Oscillate, stepCount: 2 }, + ], + sensors: [{ featureDescriptor: 'Pressure', sensorType: SensorType.Pressure, sensorRange: [0, 100] }], + }); + + const [payload] = await deviceConnected; + + const expectedAttributes = { + provider: ButtplugIoWebsocketDeviceProvider.providerName, + type: 'buttplugIo', + attributes: { + 'Vibrate-0': { + type: 'range', + modifier: DeviceAttributeModifier.writeOnly, + min: 0, + max: 20, + }, + 'Oscillate-1': { + type: 'bool', + modifier: DeviceAttributeModifier.writeOnly, + }, + 'Pressure-0': { + type: 'range', + modifier: DeviceAttributeModifier.readOnly, + min: 0, + max: 100, + }, + }, + }; + + expect(payload).toMatchObject(expectedAttributes); + + // GET /device/:id should also return the same attributes + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedAttributes); + + // GET /devices should list the device as well and return the same attributes for it + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedAttributes); + }); + + it('attribute value can be set', async () => { + simulator.receivedScalarCmds = []; + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + simulator.addDevice({ + name: 'MockVibe', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: ActuatorType.Vibrate, stepCount: 20 }], + }); + const [payload] = await deviceConnected; + + await request(app.httpServer) + .patch(`/device/${payload.deviceId}`) + .send({ 'Vibrate-0': 10 }) + .expect(202); + + expect(simulator.receivedScalarCmds).toHaveLength(1); + const cmd = simulator.receivedScalarCmds[0]; + expect(cmd?.actuatorType).toBe('Vibrate'); + expect(cmd?.index).toBe(0); + expect(cmd?.scalar).toBeCloseTo(0.5); + + // via WebSocket event + simulator.receivedScalarCmds = []; + + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId: payload.deviceId, data: { 'Vibrate-0': 5 } }); + + const [payloadDeviceRefreshed] = await deviceRefreshed; + + expect(payloadDeviceRefreshed).toMatchObject({ deviceId: payload.deviceId }); + + expect(simulator.receivedScalarCmds).toHaveLength(1); + const wsCmd = simulator.receivedScalarCmds[0]; + expect(wsCmd?.actuatorType).toBe('Vibrate'); + expect(wsCmd?.index).toBe(0); + expect(wsCmd?.scalar).toBeCloseTo(0.25); // 5 / stepCount(20) = 0.25 + }); + + it('device refreshes', async () => { + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + const deviceIndex = simulator.addDevice({ + name: 'MockSensor2', + sensors: [{ featureDescriptor: 'Pressure', sensorType: SensorType.Pressure, sensorRange: [0, 100], reading: 42 }], + }); + + const [payloadDeviceConnected] = await deviceConnected; + + assert('deviceId' in payloadDeviceConnected); + + const nextReading = 77; + + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + simulator.setSensorReading(deviceIndex, 0, nextReading); + const [payloadDeviceRefreshed] = await deviceRefreshed; + + const expectedPayload = { deviceId: payloadDeviceConnected.deviceId, attributes: { 'Pressure-0': { value: nextReading } } }; + + expect(payloadDeviceRefreshed).toMatchObject(expectedPayload); + + const res = await request(app.httpServer).get(`/device/${payloadDeviceConnected.deviceId}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject(expectedPayload); + }); + + it('device disconnected', async () => { + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + const deviceIndex = simulator.addDevice({ + name: 'MockDevice', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: ActuatorType.Vibrate, stepCount: 20 }], + }); + + const [payload] = await deviceConnected; + + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + simulator.removeDevice(deviceIndex); + const [payloadDeviceDisconnected] = await deviceDisconnected; + + expect(payloadDeviceDisconnected).toMatchObject({ deviceId: payload.deviceId }); + + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + }); +}); diff --git a/tests/integration/devices/estim2bDevice.spec.ts b/tests/integration/devices/estim2bDevice.spec.ts new file mode 100644 index 00000000..866bfb1c --- /dev/null +++ b/tests/integration/devices/estim2bDevice.spec.ts @@ -0,0 +1,224 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import EStim2bSerialDeviceProvider from '../../../src/device/protocol/estim2b/estim2bSerialDeviceProvider.js'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { Estim2bDeviceSimulator } from '../helpers/estim2bDeviceSimulator.js'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const PORT_PATH = '/dev/test-estim2b-0'; + +const SERIAL_SOURCE_ID = 'e6f7a8b9-6789-4321-abcd-ef1234567895'; + +const serialSettings = { + knownDevices: {}, + deviceSources: { + [SERIAL_SOURCE_ID]: { + id: SERIAL_SOURCE_ID, + type: EStim2bSerialDeviceProvider.providerName, + config: {}, + }, + }, +}; + +describe('E-Stim Systems 2B serial device provider', () => { + let app: TestApp; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + app = await createTestApp(serialSettings); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + app.mockSerialPortFactory.reset(); + }); + + beforeEach(async () => { + await app.container.get('device.manager').reset(); + app.mockSerialPortFactory.reset(); + await app.container.get('device.observer.serial').discoverSerialDevices(); + wsEmitSpy.mockClear(); + }); + + it('new device gets detected', async () => { + const simulator = new Estim2bDeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + + await app.container.get('device.observer.serial').discoverSerialDevices(); + + const [payload] = await deviceConnected; + + const expectedDeviceObject = { + provider: EStim2bSerialDeviceProvider.providerName, + type: 'estim2b', + attributes: { + mode: { + name: 'mode', + modifier: 'rw', + type: 'list', + }, + channelALevel: { + name: 'channelALevel', + modifier: 'rw', + min: 0, + max: 100, + incrementStep: 1, + type: 'range', + }, + channelBLevel: { + name: 'channelBLevel', + modifier: 'rw', + min: 0, + max: 100, + incrementStep: 1, + type: 'range', + }, + highPowerMode: { + name: 'highPowerMode', + modifier: 'rw', + type: 'bool', + }, + channelsJoined: { + name: 'channelsJoined', + modifier: 'ro', + type: 'bool', + }, + batteryStatus: { + name: 'batteryStatus', + modifier: 'ro', + type: 'str', + }, + pulseFrequency: { + name: 'pulseFrequency', + modifier: 'rw', + min: 2, + max: 100, + incrementStep: 1, + type: 'range', + }, + pulsePwm: { + name: 'pulsePwm', + modifier: 'rw', + min: 2, + max: 100, + incrementStep: 1, + type: 'range', + }, + }, + config: {}, + }; + + expect(payload).toMatchObject(expectedDeviceObject); + + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedDeviceObject); + + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedDeviceObject); + }); + + it('attribute value can be set', async () => { + const simulator = new Estim2bDeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ channelALevel: 50 }) + .expect(202); + + expect(simulator.receivedCommands).toContain('A50'); + + const resAfterPatch = await request(app.httpServer).get(`/device/${deviceId}`); + expect(resAfterPatch.status).toBe(200); + expect(resAfterPatch.body.attributes.channelALevel.value).toBe(50); + + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId, data: { channelALevel: 80 } }); + await deviceRefreshed; + + expect(simulator.receivedCommands).toContain('A80'); + }); + + it('device refreshes', async () => { + const simulator = new Estim2bDeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const deviceRefreshed = waitForNextWsEvent( + wsEmitSpy, + WebSocketEvent.deviceRefreshed, + 5000, + ([p]) => p.deviceId === deviceId + && p.attributes?.channelALevel?.value === 80 + && p.attributes?.highPowerMode?.value === true, + ); + + simulator.setChannelALevel(80); + + const [refreshPayload] = await deviceRefreshed; + + const expectedPayload = { + deviceId, + attributes: { + channelALevel: { value: 80 }, + highPowerMode: { value: true }, + }, + }; + + expect(refreshPayload).toMatchObject(expectedPayload); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject(expectedPayload); + }); + + it('device disconnected', async () => { + const simulator = new Estim2bDeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const device = app.container.get('device.manager').getConnectedDevice(deviceId); + assert(device !== null); + + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + await device.close(); + const [disconnectPayload] = await deviceDisconnected; + + expect(disconnectPayload).toMatchObject({ deviceId }); + + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.items.length).toBe(0); + }); +}); diff --git a/tests/integration/devices/slvCtrlSerialDevice.spec.ts b/tests/integration/devices/slvCtrlSerialDevice.spec.ts new file mode 100644 index 00000000..e0330590 --- /dev/null +++ b/tests/integration/devices/slvCtrlSerialDevice.spec.ts @@ -0,0 +1,294 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import SlvCtrlPlusSerialDeviceProvider from '../../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { SlvCtrlPlusDeviceSimulator } from '../helpers/slvCtrlPlusDeviceSimulator.js'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const V1_PORT_PATH = '/dev/test-slvctrl-v1-0'; +const LEGACY_PORT_PATH = '/dev/test-slvctrl-legacy-0'; + +const SERIAL_SOURCE_ID = 'e6f7a8b9-6789-4321-abcd-ef1234567895'; + +const serialSettings = { + knownDevices: {}, + deviceSources: { + [SERIAL_SOURCE_ID]: { + id: SERIAL_SOURCE_ID, + type: SlvCtrlPlusSerialDeviceProvider.providerName, + config: {}, + }, + }, +}; + +describe('SlvCtrl serial device provider', () => { + let app: TestApp; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + app = await createTestApp(serialSettings); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + app.mockSerialPortFactory.reset(); + }); + + beforeEach(async () => { + // Close all connected devices before destroying the mock binding so their polling + // timers are stopped and the device-close chain completes cleanly. Without this, + // stale devices accumulate across test iterations: each one keeps a 100ms polling + // timer alive and floods the event loop with I/O errors after the binding is torn down. + await app.container.get('device.manager').reset(); + app.mockSerialPortFactory.reset(); + await app.container.get('device.observer.serial').discoverSerialDevices(); + wsEmitSpy.mockClear(); + }); + + it.each([ + { protocol: 'legacy', deviceType: 'testDeviceLegacy' }, + { protocol: 'v1', deviceType: 'testDeviceV1' }, + ] as const)('new $protocol device gets detected', async ({ protocol, deviceType }) => { + const portPath = protocol === 'v1' ? V1_PORT_PATH : LEGACY_PORT_PATH; + const simulator = new SlvCtrlPlusDeviceSimulator({ protocol, deviceType }); + app.mockSerialPortFactory.attachDevice(portPath, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + + await app.container.get('device.observer.serial').discoverSerialDevices(); + + const [payload] = await deviceConnected; + + const expectedDeviceObject = { + provider: SlvCtrlPlusSerialDeviceProvider.providerName, + type: 'slvCtrlPlus', + attributes: { + connected: { + name: 'connected', + modifier: 'ro', + type: 'bool' + }, + enabled: { + name: 'enabled', + modifier: 'rw', + type: 'bool' + }, + counter: { + name: 'counter', + modifier: 'ro', + type: 'int' + }, + level: { + name: 'level', + modifier: 'rw', + type: 'int' + }, + temperature: { + name: 'temperature', + modifier: 'ro', + type: 'float' + }, + gain: { + name: 'gain', + modifier: 'rw', + type: 'float' + }, + label: { + name: 'label', + modifier: 'ro', + type: 'str' + }, + mode: { + name: 'mode', + modifier: 'rw', + type: 'str' + }, + intensity: { + name: 'intensity', + modifier: 'rw', + min: 0, + max: 100, + incrementStep: 1, + type: 'range' + }, + preset: { + name: 'preset', + modifier: 'rw', + values: [ + { + "key": "low", + "value": "low", + }, + { + "key": "medium", + "value": "medium", + }, + { + "key": "high", + "value": "high", + } + ], + type: 'list' + }, + channel: { + name: 'channel', + modifier: 'rw', + values: [ + { + "key": (protocol === 'legacy') ? "1" : 1, + "value": (protocol === 'legacy') ? "1" : 1, + }, + { + "key": (protocol === 'legacy') ? "2" : 2, + "value": (protocol === 'legacy') ? "2" : 2, + }, + { + "key": (protocol === 'legacy') ? "3" : 3, + "value": (protocol === 'legacy') ? "3" : 3, + } + ], + type: 'list' + } + }, + config: {}, + deviceModel: deviceType, + fwVersion: 1, + }; + + expect(payload).toMatchObject(expectedDeviceObject); + + // GET /device/:id should also return the same attributes + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedDeviceObject); + + // GET /devices should list the device as well and return the same attributes for it + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedDeviceObject); + }); + + it.each([ + { protocol: 'legacy', deviceType: 'testDeviceLegacy' }, + { protocol: 'v1', deviceType: 'testDeviceV1' }, + ] as const)('$protocol attribute value can be set', async ({ protocol, deviceType }) => { + const portPath = protocol === 'v1' ? V1_PORT_PATH : LEGACY_PORT_PATH; + const simulator = new SlvCtrlPlusDeviceSimulator({ protocol, deviceType }); + app.mockSerialPortFactory.attachDevice(portPath, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Set attribute value via REST API + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ level: 7 }) + .expect(202); + + expect(simulator.getValue('level')).toBe('7'); + + const resAfterPatch = await request(app.httpServer).get(`/device/${deviceId}`); + expect(resAfterPatch.status).toBe(200); + expect(resAfterPatch.body.attributes.level.value).toBe(7); + + // Set attribute value via Websocket + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed, 5000, ([p]) => { + return p.deviceId === deviceId && p.attributes?.level?.value === 9; + }); + + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId, data: { level: 9 } }); + await deviceRefreshed; + + expect(simulator.getValue('level')).toBe('9'); + }); + + it.each([ + { protocol: 'legacy', deviceType: 'testDeviceLegacy' }, + { protocol: 'v1', deviceType: 'testDeviceV1' }, + ] as const)('$protocol device refreshes', async ({ protocol, deviceType }) => { + const portPath = protocol === 'v1' ? V1_PORT_PATH : LEGACY_PORT_PATH; + const simulator = new SlvCtrlPlusDeviceSimulator({ protocol, deviceType }); + app.mockSerialPortFactory.attachDevice(portPath, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const deviceRefreshed = waitForNextWsEvent( + wsEmitSpy, + WebSocketEvent.deviceRefreshed, + 5000, + ([p]) => p.deviceId === deviceId + && p.attributes?.level?.value === 8 + && p.attributes?.enabled?.value === true, + ); + + simulator.setValue('level', '8'); + simulator.setValue('enabled', '1'); + + const [refreshPayload] = await deviceRefreshed; + + const expectedPayload = { + deviceId, + attributes: { + level: { value: 8 }, + enabled: { value: true }, + }, + }; + + expect(refreshPayload).toMatchObject(expectedPayload); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject(expectedPayload); + }); + + it.each([ + { protocol: 'legacy', deviceType: 'testDeviceLegacy' }, + { protocol: 'v1', deviceType: 'testDeviceV1' }, + ] as const)('$protocol device disconnected', async ({ protocol, deviceType }) => { + const portPath = protocol === 'v1' ? V1_PORT_PATH : LEGACY_PORT_PATH; + const simulator = new SlvCtrlPlusDeviceSimulator({ protocol, deviceType }); + app.mockSerialPortFactory.attachDevice(portPath, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Get device by its specific ID to avoid ambiguity with stale devices + const device = app.container.get('device.manager').getConnectedDevice(deviceId); + assert(device !== null); + + // Closing the MockPortBinding via simulator.disconnect() does not propagate a 'close' event + // to the SerialPortStream reliably. Call device.close() directly instead, which is the + // correct way to trigger the device lifecycle events (same as a real port close). + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + await device.close(); + const [disconnectPayload] = await deviceDisconnected; + + expect(disconnectPayload).toMatchObject({ deviceId }); + + // The specific device must no longer appear in the device list + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.items.length).toBe(0); + }); +}); diff --git a/tests/integration/devices/zc95Device.spec.ts b/tests/integration/devices/zc95Device.spec.ts new file mode 100644 index 00000000..95934dd8 --- /dev/null +++ b/tests/integration/devices/zc95Device.spec.ts @@ -0,0 +1,327 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import Zc95SerialDeviceProvider from '../../../src/device/protocol/zc95/zc95SerialDeviceProvider.js'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { Zc95DeviceSimulator } from '../helpers/zc95DeviceSimulator.js'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const PORT_PATH = '/dev/test-zc95-0'; + +const SERIAL_SOURCE_ID = 'e6f7a8b9-6789-4321-abcd-ef1234567895'; + +const serialSettings = { + knownDevices: {}, + deviceSources: { + [SERIAL_SOURCE_ID]: { + id: SERIAL_SOURCE_ID, + type: Zc95SerialDeviceProvider.providerName, + config: {}, + }, + }, +}; + +describe('Zc95 serial device provider', () => { + let app: TestApp; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + app = await createTestApp(serialSettings); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + app.mockSerialPortFactory.reset(); + }); + + beforeEach(async () => { + await app.container.get('device.manager').reset(); + app.mockSerialPortFactory.reset(); + await app.container.get('device.observer.serial').discoverSerialDevices(); + wsEmitSpy.mockClear(); + }); + + it('new device gets detected', async () => { + const simulator = new Zc95DeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + + await app.container.get('device.observer.serial').discoverSerialDevices(); + + const [payload] = await deviceConnected; + + const expectedDeviceObject = { + provider: Zc95SerialDeviceProvider.providerName, + type: 'zc95', + attributes: { + activePattern: { + name: 'activePattern', + modifier: 'rw', + values: [{ key: 0, value: 'Test Pattern' }], + type: 'list', + }, + patternStarted: { + name: 'patternStarted', + modifier: 'rw', + type: 'bool', + value: false, + }, + }, + config: {}, + }; + + expect(payload).toMatchObject(expectedDeviceObject); + + // GET /device/:id should also return the same attributes + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedDeviceObject); + + // GET /devices should list the device as well + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedDeviceObject); + }); + + it('attribute value can be set', async () => { + const simulator = new Zc95DeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Set attribute value via REST API – setAttribute() calls updateLastRefresh() which emits deviceRefreshed + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ patternStarted: true }) + .expect(202); + + expect(simulator.receivedCommands.map(c => c.type)).toContain('PatternStart'); + + const resAfterPatch = await request(app.httpServer).get(`/device/${deviceId}`); + expect(resAfterPatch.status).toBe(200); + expect(resAfterPatch.body.attributes.patternStarted.value).toBe(true); + + // Set attribute value via WebSocket – setAttribute() also calls updateLastRefresh() + // so we can wait for the resulting deviceRefreshed event. + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId, data: { patternStarted: false } }); + await deviceRefreshed; + + expect(simulator.receivedCommands.map(c => c.type)).toContain('PatternStop'); + }); + + it('started pattern exposes power channel and pattern-specific attributes', async () => { + const simulator = new Zc95DeviceSimulator({ + patterns: [ + { + id: 0, + name: 'Test Pattern', + menuItems: [ + { + Id: 1, + Title: 'Intensity', + Group: 0, + Type: 'MIN_MAX', + Default: 50, + Min: 0, + Max: 100, + IncrementStep: 1, + UoM: '%', + }, + { + Id: 2, + Title: 'Waveform', + Group: 0, + Type: 'MULTI_CHOICE', + Default: 0, + Choices: [ + { Id: 0, Name: 'Sine' }, + { Id: 1, Name: 'Square' }, + ], + }, + ], + }, + ], + }); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ patternStarted: true }) + .expect(202); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + + const attrs = res.body.attributes; + + // Core pattern-state attributes remain present + expect(attrs).toMatchObject({ + activePattern: { name: 'activePattern', modifier: 'rw', type: 'list', value: 0 }, + patternStarted: { name: 'patternStarted', modifier: 'rw', type: 'bool', value: true }, + }); + + // Four power-channel range attributes are created (min=max=0, value not yet set by device) + for (let ch = 1; ch <= 4; ch++) { + expect(attrs[`powerChannel${ch}`]).toMatchObject({ + name: `powerChannel${ch}`, + modifier: 'rw', + type: 'range', + min: 0, + max: 0, + incrementStep: 1, + }); + } + + // MIN_MAX menu item (Id=1) → IntRangeDeviceAttribute initialised to Default value + expect(attrs.patternAttribute1).toMatchObject({ + name: 'patternAttribute1', + modifier: 'rw', + type: 'range', + min: 0, + max: 100, + incrementStep: 1, + value: 50, + }); + + // MULTI_CHOICE menu item (Id=2) → ListDeviceAttribute initialised to Default choice + expect(attrs.patternAttribute2).toMatchObject({ + name: 'patternAttribute2', + modifier: 'rw', + type: 'list', + values: [ + { key: 0, value: 'Sine' }, + { key: 1, value: 'Square' }, + ], + value: 0, + }); + }); + + it('device refreshes', async () => { + // Use a pattern with real menu items so we can verify that the unsolicited + // PowerStatus message only updates the power-channel attributes and leaves + // the pattern-specific attributes untouched. + const simulator = new Zc95DeviceSimulator({ + patterns: [ + { + id: 0, + name: 'Test Pattern', + menuItems: [ + { + Id: 1, + Title: 'Intensity', + Group: 0, + Type: 'MIN_MAX', + Default: 50, + Min: 0, + Max: 100, + IncrementStep: 1, + UoM: '%', + }, + ], + }, + ], + }); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Start the pattern so the powerChannel and patternAttribute attributes are created. + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ patternStarted: true }) + .expect(202); + + // The ZC95 firmware periodically broadcasts unsolicited PowerStatus messages. + // PowerStatusMsgResponse only carries Channels (Channel, OutputPower, MaxOutputPower, + // PowerLimit). processPowerStatusMessage() updates powerChannel attributes and calls + // updateLastRefresh(), which emits deviceRefreshed. + // Raw values are converted via * 0.1: + // powerChannel1 value = Math.floor(200 * 0.1) = 20 + // powerChannel1 max = Math.floor(1000 * 0.1) = 100 + const deviceRefreshed = waitForNextWsEvent( + wsEmitSpy, + WebSocketEvent.deviceRefreshed, + 5000, + ([p]) => p.deviceId === deviceId && p.attributes?.powerChannel1?.value === 20, + ); + + simulator.sendPowerStatus([ + { channel: 1, outputPower: 200, maxOutputPower: 200, powerLimit: 1000 }, + { channel: 2, outputPower: 0, maxOutputPower: 0, powerLimit: 1000 }, + { channel: 3, outputPower: 0, maxOutputPower: 0, powerLimit: 1000 }, + { channel: 4, outputPower: 0, maxOutputPower: 0, powerLimit: 1000 }, + ]); + + const [refreshPayload] = await deviceRefreshed; + + // Power channels are updated by the PowerStatus message. + expect(refreshPayload).toMatchObject({ + deviceId, + attributes: { + powerChannel1: { value: 20, max: 100 }, + }, + }); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + + const attrs = res.body.attributes; + + // Power channels carry the values broadcast in the PowerStatus message. + expect(attrs.powerChannel1).toMatchObject({ value: 20, max: 100 }); + + // Pattern-specific attributes are NOT part of the PowerStatus message: + // they retain the initial values set when the pattern was started. + expect(attrs.patternAttribute1).toMatchObject({ value: 50 }); + }); + + it('device disconnected', async () => { + const simulator = new Zc95DeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const device = app.container.get('device.manager').getConnectedDevice(deviceId); + assert(device !== null); + + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + await device.close(); + const [disconnectPayload] = await deviceDisconnected; + + expect(disconnectPayload).toMatchObject({ deviceId }); + + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.items.length).toBe(0); + }); +}); diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts new file mode 100644 index 00000000..6a6bca72 --- /dev/null +++ b/tests/integration/helpers/appHelper.ts @@ -0,0 +1,246 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { io as ioClient } from 'socket.io-client'; +import { createApp, AppInstance, createContainer, AppOptions } from '../../../src/app.js'; +import Device from '../../../src/device/device.js'; +import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; +import { ServerToClientEvents } from '../../../src/socket/types.js'; +type WsEmitCall = { [E in keyof ServerToClientEvents]: [E, ...Parameters] }[keyof ServerToClientEvents]; +import KnownDevice from '../../../src/settings/knownDevice.js'; +import Settings from '../../../src/settings/settings.js'; +import DeviceSource from '../../../src/settings/deviceSource.js'; +import ServiceMap from '../../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; +import MockSerialPortFactory from './mockSerialPortFactory.js'; +import http from 'http'; +import { AddressInfo } from 'net'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +export const TEST_DEVICE_ID = 'a1b2c3d4-1234-4321-abcd-ef1234567890'; +export const TEST_SOURCE_ID = 'b2c3d4e5-2345-4321-abcd-ef1234567891'; +export const NEW_DEVICE_ID = 'c3d4e5f6-3456-4321-abcd-ef1234567892'; + +export type DeviceSpec = { id: string, name: string, config?: { min: number, max: number } }; + +function makeBaseSettings(): Settings { + const settings = new Settings(); + settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + return settings; +} + +const baseSettingsJson = { + knownDevices: {}, + deviceSources: { + [TEST_SOURCE_ID]: { + id: TEST_SOURCE_ID, + type: 'virtual', + config: { scanIntervalMs: 50 }, + }, + }, +}; + +export type TestApp = Omit & { + container: Container, + tmpDir: string, + mockSerialPortFactory: MockSerialPortFactory, + httpServer: http.Server +}; + +export const createTestApp = async ( + settingsJson: object = baseSettingsJson +): Promise => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); + const dataPath = tmpDir + path.sep; + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify(settingsJson)); + + const options: AppOptions = { dataPath, allowedOrigins: ['*'] }; + const container = createContainer(dataPath); + + const mockSerialPortFactory = new MockSerialPortFactory(); + container.replace('factory.serialPort', () => mockSerialPortFactory); + + const { websocket, serve, shutdown } = createApp(container, options); + const httpServer = serve(0).httpServer; + + return { container, tmpDir, mockSerialPortFactory, websocket, httpServer, shutdown }; +}; + +export const createWsClient = async (httpServer: http.Server): Promise> => { + const wsClient = ioClient(`http://localhost:${getServerPort(httpServer)}`); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + wsClient.close(); + reject(new Error('Failed to connect WebSocket client')); + }, 2000); + + wsClient.on('connect', () => { clearTimeout(timeout); resolve(); }); + wsClient.on('connect_error', (err) => { + clearTimeout(timeout); + wsClient.close(); + reject(err); + }); + }); + + return wsClient; +}; + +export const teardownTestApp = async (app: TestApp): Promise => { + if (app.httpServer !== undefined) { + app.httpServer.closeAllConnections(); + } + + await app.shutdown(); + + fs.rmSync(app.tmpDir, { recursive: true }); +}; + +export const resetTestApp = async (app: TestApp): Promise => { + const deviceManager = app.container.get('device.manager'); + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const settingsManager = app.container.get('settings.manager'); + + if (scriptRuntime.isRunning()) { + await scriptRuntime.stop(); + } + + const connectedIds = deviceManager.getConnectedDevices().map(d => d.getDeviceId); + + if (connectedIds.length > 0) { + // Drive disconnection through settings so VirtualDeviceProvider clears its + // own attemptedDevices/connectedDevices maps via its normal discovery loop. + // Calling device.close() directly would leave those maps stale. + const remaining = new Set(connectedIds); + const allGone = new Promise((resolve, reject) => { + const cleanup = () => { + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + clearTimeout(timeout); + }; + const timeout = setTimeout( + () => { + cleanup(); + reject(new Error(`resetTestApp: ${[...remaining].join(', ')} did not disconnect within 1s`)); + }, + 1000, + ); + const listener = (device: { getDeviceId: string }) => { + remaining.delete(device.getDeviceId); + if (remaining.size === 0) { + cleanup(); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + settingsManager.replace(makeBaseSettings()); + await allGone; + } else { + settingsManager.replace(makeBaseSettings()); + } +}; + +export function getConnectedDevice( + container: Container, + predicate: (device: Device) => boolean, + description: string, +): Device { + const device = container.get('device.manager').getConnectedDevices().find(predicate); + if (undefined === device) { + throw new Error(`No connected device found: ${description}`); + } + return device; +} + +export function waitForNDevicesConnected(container: Container, deviceCount: number, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const deviceManager = container.get('device.manager'); + const connected: Device[] = []; + + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + reject(new Error(`Timed out waiting for ${deviceCount} device(s) to connect (>${timeoutMs}ms), got ${connected.length}`)); + }, timeoutMs); + + const listener = (device: Device): void => { + connected.push(device); + if (connected.length >= deviceCount) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(connected); + } + }; + + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); +} + +export function waitForNextWsEvent( + wsEmitSpy: { mock: { calls: ReadonlyArray } }, + event: E, + timeoutMs = 5000, + predicate?: (params: Parameters) => boolean, +): Promise> { + const matchingCalls = () => wsEmitSpy.mock.calls + .filter((call): call is Extract]> => call[0] === event) + .map(([, ...params]) => params as Parameters) + .filter(params => predicate === undefined || predicate(params)); + const countBefore = matchingCalls().length; + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + const poll = () => { + const calls = matchingCalls(); + if (calls.length > countBefore) { + resolve(calls[calls.length - 1]); + } else if (Date.now() >= deadline) { + reject(new Error(`Timed out waiting for WS event '${event}' (>${timeoutMs}ms)`)); + } else { + setTimeout(poll, 10); + } + }; + poll(); + }); +} + +export const getServerPort = (server: http.Server): number => { + const address = server.address(); + if (address !== null && typeof address === 'object') { + return address.port; + } + throw new Error('Server address is not an AddressInfo'); +} + +export const connectDevices = (container: Container, specs: DeviceSpec[]): Promise => { + const deviceManager = container.get('device.manager'); + const settingsManager = container.get('settings.manager'); + + const pendingIds = new Set(specs.map(s => s.id)); + + const allConnected = new Promise((resolve, reject) => { + const listener = (device: { getDeviceId: string }) => { + pendingIds.delete(device.getDeviceId); + if (pendingIds.size === 0) { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + clearTimeout(timeout); + resolve(); + } + }; + + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + reject(new Error(`Timed out waiting for devices to connect: ${[...pendingIds].join(', ')}`)); + }, 1000); + + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); + + const settings = settingsManager.load(); + for (const spec of specs) { + settings.addKnownDevice(new KnownDevice(spec.id, spec.id, spec.name, 'randomGenerator', 'virtual', spec.config ?? { min: 0, max: 100 })); + } + settingsManager.replace(settings); + + return allConnected; +}; diff --git a/tests/integration/helpers/buttplugIoServerSimulator.ts b/tests/integration/helpers/buttplugIoServerSimulator.ts new file mode 100644 index 00000000..93881bf4 --- /dev/null +++ b/tests/integration/helpers/buttplugIoServerSimulator.ts @@ -0,0 +1,266 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { createServer } from 'http'; +import { ActuatorType, SensorType, RequestServerInfo, RequestDeviceList, StartScanning, StopAllDevices, StopDeviceCmd, SensorReadCmd, ScalarCmd, StopScanning, Ping, fromJSON } from 'buttplug'; + +/** + * Defines a mock actuator on a simulated Buttplug device. + */ +export interface MockActuator { + featureDescriptor: string; + actuatorType: ActuatorType; + stepCount: number; +} + +/** + * Defines a mock sensor on a simulated Buttplug device. + * sensorRange is [min, max]. + */ +export interface MockSensor { + featureDescriptor: string; + sensorType: SensorType; + sensorRange: [number, number]; + /** Current reading to return for SensorReadCmd (defaults to 0). */ + reading?: number; +} + +export interface MockButtplugDevice { + name: string; + displayName?: string; + actuators?: MockActuator[]; + sensors?: MockSensor[]; +} + +type IncomingScalarSubcommand = { Index: number; Scalar: number; ActuatorType: ActuatorType }; + +type IncomingMessage = + RequestServerInfo | + RequestDeviceList | + StartScanning | + StopScanning | + ScalarCmd | + SensorReadCmd | + StopDeviceCmd | + StopAllDevices | + Ping; + +/** + * Minimal WebSocket server that speaks the Buttplug JSON protocol v3. + * + * Usage: + * const sim = new ButtplugIoServerSimulator(); + * const port = await sim.start(); + * // address used in provider config: `localhost:${port}` + * sim.addDevice({ name: 'MockVibe', actuators: [...], sensors: [...] }); + * ... + * await sim.stop(); + */ +export class ButtplugIoServerSimulator { + private server: ReturnType | null = null; + private wss: WebSocketServer | null = null; + private connectedClients: Set = new Set(); + private clientConnectedResolvers: Array<() => void> = []; + private clientReadyResolvers: Array<() => void> = []; + + private devices: Map = new Map(); + private nextDeviceIndex = 0; + + /** Recorded scalar commands: { deviceIndex, index, actuatorType, scalar }[] */ + public receivedScalarCmds: Array<{ + deviceIndex: number; + index: number; + actuatorType: ActuatorType; + scalar: number; + }> = []; + + public async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer(); + this.wss = new WebSocketServer({ server: this.server, path: '/buttplug' }); + + this.wss.on('connection', (ws: WebSocket) => { + this.connectedClients.add(ws); + ws.on('message', (data: Buffer) => this.handleMessage(ws, data.toString())); + ws.on('close', () => this.connectedClients.delete(ws)); + ws.on('error', () => this.connectedClients.delete(ws)); + for (const resolve of this.clientConnectedResolvers) resolve(); + this.clientConnectedResolvers = []; + }); + + this.server.listen(0, '127.0.0.1', () => { + const addr = this.server?.address(); + if (addr && typeof addr === 'object') { + resolve(addr.port); + } else { + reject(new Error('Could not determine server port')); + } + }); + + this.server.on('error', reject); + }); + } + + public removeAllDevices(): void { + for (const deviceIndex of [...this.devices.keys()]) { + this.removeDevice(deviceIndex); + } + } + + /** Resolves once the buttplug client has completed its handshake (RequestDeviceList received). */ + public waitForClientReady(timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for buttplug client to be ready (>${timeoutMs}ms)`)), timeoutMs); + this.clientReadyResolvers.push(() => { clearTimeout(timer); resolve(); }); + }); + } + + public async stop(): Promise { + for (const ws of this.connectedClients) { + ws.close(); + } + + this.connectedClients.clear(); + + await new Promise((resolve, reject) => { + if (this.wss === null) { + resolve(); + return; + } + + this.wss.close(err => err ? reject(err) : resolve()); + }); + await new Promise((resolve, reject) => { + if (this.server === null) { + resolve(); + return; + } + + this.server.close(err => err ? reject(err) : resolve()); + }); + } + + /** + * Add a device and notify all connected clients with a DeviceAdded push. + * Returns the assigned device index. + */ + public addDevice(device: MockButtplugDevice): number { + const index = this.nextDeviceIndex++; + this.devices.set(index, device); + + const msg = this.buildDeviceAddedMessage(index, device); + this.broadcast(msg); + + return index; + } + + /** + * Remove a device and notify all connected clients with a DeviceRemoved push. + */ + public removeDevice(deviceIndex: number): void { + this.devices.delete(deviceIndex); + this.broadcast(`[{"DeviceRemoved":{"Id":0,"DeviceIndex":${deviceIndex}}}]`); + } + + public setSensorReading(deviceIndex: number, sensorIndex: number, value: number): void { + const device = this.devices.get(deviceIndex); + const sensor = device?.sensors?.[sensorIndex]; + if (sensor) { + sensor.reading = value; + } + } + + private handleMessage(ws: WebSocket, raw: string): void { + for (const msg of fromJSON(raw)) { + this.handleSingleMessage(ws, msg); + } + } + + private handleSingleMessage(ws: WebSocket, msg: IncomingMessage): void { + if (msg instanceof RequestServerInfo) { + ws.send( + `[{"ServerInfo":{"Id":${msg.Id},"MessageVersion":3,"MaxPingTime":0,"ServerName":"MockButtplugServer"}}]` + ); + } else if (msg instanceof RequestDeviceList) { + const deviceList = [...this.devices.entries()].map(([idx, dev]) => + JSON.stringify(this.buildDeviceInfo(idx, dev)) + ); + ws.send( + `[{"DeviceList":{"Id":${msg.Id},"Devices":[${deviceList.join(',')}]}}]` + ); + for (const resolve of this.clientReadyResolvers) resolve(); + this.clientReadyResolvers = []; + } else if (msg instanceof StartScanning) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + ws.send(`[{"ScanningFinished":{"Id":0}}]`); + } else if (msg instanceof StopScanning) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } else if (msg instanceof ScalarCmd) { + for (const s of msg.Scalars) { + this.receivedScalarCmds.push({ + deviceIndex: msg.DeviceIndex, + index: s.Index, + actuatorType: s.ActuatorType, + scalar: s.Scalar, + }); + } + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } else if (msg instanceof SensorReadCmd) { + const device = this.devices.get(msg.DeviceIndex); + const sensor = device?.sensors?.[msg.SensorIndex]; + const reading = sensor?.reading ?? 0; + ws.send( + `[{"SensorReading":{"Id":${msg.Id},"DeviceIndex":${msg.DeviceIndex},"SensorIndex":${msg.SensorIndex},"SensorType":"${msg.SensorType}","Data":[${reading},0]}}]` + ); + } else if (msg instanceof StopDeviceCmd) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } else if (msg instanceof StopAllDevices) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } else if (msg instanceof Ping) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } + } + + private broadcast(msg: string): void { + for (const ws of this.connectedClients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } + } + } + + private buildDeviceAddedMessage(index: number, device: MockButtplugDevice): string { + return `[{"DeviceAdded":${JSON.stringify({ + Id: 0, + ...this.buildDeviceInfo(index, device), + })}}]`; + } + + private buildDeviceInfo(index: number, device: MockButtplugDevice): object { + const deviceMessages: Record = {}; + + if (device.actuators && device.actuators.length > 0) { + deviceMessages['ScalarCmd'] = device.actuators.map((a, i) => ({ + FeatureDescriptor: a.featureDescriptor, + ActuatorType: a.actuatorType, + StepCount: a.stepCount, + Index: i, + })); + } + + if (device.sensors && device.sensors.length > 0) { + deviceMessages['SensorReadCmd'] = device.sensors.map((s, i) => ({ + FeatureDescriptor: s.featureDescriptor, + SensorType: s.sensorType, + SensorRange: s.sensorRange, + Index: i, + })); + } + + return { + DeviceIndex: index, + DeviceName: device.name, + DeviceDisplayName: device.displayName ?? device.name, + DeviceMessageTimingGap: 0, + DeviceMessages: deviceMessages, + }; + } +} diff --git a/tests/integration/helpers/estim2bDeviceSimulator.ts b/tests/integration/helpers/estim2bDeviceSimulator.ts new file mode 100644 index 00000000..a4613cd6 --- /dev/null +++ b/tests/integration/helpers/estim2bDeviceSimulator.ts @@ -0,0 +1,166 @@ +import type { SerialPortMock } from 'serialport'; + +type MockPortBinding = NonNullable['port']>; + +export class Estim2bDeviceSimulator { + private port: MockPortBinding | null = null; + + private batteryLevel: number; + private channelALevel: number; + private channelBLevel: number; + private pulseFrequency: number; + private pulsePwm: number; + private currentMode: number; + private powerMode: 'H' | 'L'; + private channelsJoined: boolean; + private readonly firmwareVersion: string; + + public readonly receivedCommands: string[] = []; + + public constructor(config?: { firmwareVersion?: string }) { + this.batteryLevel = 800; + this.channelALevel = 0; + this.channelBLevel = 0; + this.pulseFrequency = 50; + this.pulsePwm = 50; + this.currentMode = 0; + this.powerMode = 'H'; + this.channelsJoined = false; + this.firmwareVersion = config?.firmwareVersion ?? 'test-1.0'; + } + + public attachToPort(bindingPort: MockPortBinding): void { + this.port = bindingPort; + const originalWrite = bindingPort.write.bind(bindingPort); + + bindingPort.write = async (buffer: Buffer): Promise => { + await originalWrite(buffer); + + const raw = buffer.toString('utf-8'); + const command = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + + this.receivedCommands.push(command); + + this.handleCommand(command); + + setImmediate(() => { + bindingPort.emitData(Buffer.from(this.buildStatusResponse() + '\n', 'utf-8')); + }); + }; + } + + public disconnect(): Promise { + if (this.port === null) { + return Promise.resolve(); + } + return this.port.close(); + } + + public setChannelALevel(value: number): void { + this.channelALevel = value; + } + + public setChannelBLevel(value: number): void { + this.channelBLevel = value; + } + + public setMode(mode: number): void { + this.currentMode = mode; + } + + public setPowerMode(mode: 'H' | 'L'): void { + this.powerMode = mode; + } + + public setPulseFrequency(value: number): void { + this.pulseFrequency = value; + } + + public setPulsePwm(value: number): void { + this.pulsePwm = value; + } + + public setBatteryLevel(adc: number): void { + this.batteryLevel = adc; + } + + private handleCommand(command: string): void { + if (command === '') { + return; + } + + if (command === 'H') { + this.powerMode = 'H'; + return; + } + + if (command === 'L') { + this.powerMode = 'L'; + return; + } + + if (command === 'K') { + this.channelALevel = 0; + this.channelBLevel = 0; + return; + } + + if (command === 'E') { + this.channelALevel = 0; + this.channelBLevel = 0; + this.currentMode = 0; + this.pulseFrequency = 50; + this.pulsePwm = 50; + this.powerMode = 'L'; + return; + } + + if (command.startsWith('A')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 0 && value <= 99) { + this.channelALevel = value; + } + return; + } + + if (command.startsWith('B')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 0 && value <= 99) { + this.channelBLevel = value; + } + return; + } + + if (command.startsWith('M')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 0 && value <= 16) { + this.currentMode = value; + } + return; + } + + if (command.startsWith('C')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 2 && value <= 100) { + this.pulseFrequency = value; + } + return; + } + + if (command.startsWith('D')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 2 && value <= 100) { + this.pulsePwm = value; + } + return; + } + } + + private buildStatusResponse(): string { + const chARaw = Math.round(this.channelALevel * 2); + const chBRaw = Math.round(this.channelBLevel * 2); + const freqRaw = Math.round(this.pulseFrequency * 2); + const pwmRaw = Math.round(this.pulsePwm * 2); + return `${this.batteryLevel}:${chARaw}:${chBRaw}:${freqRaw}:${pwmRaw}:${this.currentMode}:${this.powerMode}:${this.channelsJoined ? 1 : 0}:${this.firmwareVersion}`; + } +} diff --git a/tests/integration/helpers/mockSerialPortFactory.ts b/tests/integration/helpers/mockSerialPortFactory.ts new file mode 100644 index 00000000..1175bff2 --- /dev/null +++ b/tests/integration/helpers/mockSerialPortFactory.ts @@ -0,0 +1,68 @@ +import { SerialPort, SerialPortMock } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import { SerialPortOpenOptions } from 'serialport'; +import { AutoDetectTypes } from '@serialport/bindings-cpp'; +import SerialPortFactory from '../../../src/factory/serialPortFactory.js'; + +export interface DeviceSimulator { + attachToPort(bindingPort: NonNullable['port']>): void; +} + +/** + * A SerialPortFactory that creates SerialPortMock instances backed by + * MockBinding instead of real hardware. + * + * Supports routing different port paths to different simulators via a Map. + */ +export default class MockSerialPortFactory extends SerialPortFactory { + private readonly simulators: Map = new Map(); + + // Incremented on every attachDevice call so each mock port gets a unique serialNumber. + // Without this the SerialPortObserver synthesizes the same "serial-1234-5678-undefined" + // for every mock port (vendorId + productId + missing locationId), causing all devices + // across all test iterations to share a single UUID via createKnownDevice. A unique + // serial per connection means each device gets its own UUID and stale-device async-close + // events can never accidentally evict a different test's device from the DeviceManager map. + private productId = 1000; + + public constructor() { + super(); + + SerialPort.list = () => SerialPortMock.list(); + } + + public attachDevice(path: string, simulator: DeviceSimulator): void { + SerialPortMock.binding.createPort(path, { echo: false, record: false, vendorId: '1234', productId: String(++this.productId), manufacturer: 'NotArduino' }); + + this.simulators.set(path, simulator); + } + + public reset(): void { + this.simulators.clear(); + SerialPortMock.binding.reset(); + } + + public override create(options: SerialPortOpenOptions): SerialPortStream { + const mockPort = new SerialPortMock({ + path: options.path, + baudRate: options.baudRate ?? 9600, + autoOpen: false, + }); + + const simulator = this.simulators.get(options.path); + + if (!simulator) { + throw new Error(`No simulator attached for path ${options.path}`); + } + + // Attach the simulator once the port binding is ready (after open). + mockPort.once('open', () => { + if (mockPort.port && simulator) { + simulator.attachToPort(mockPort.port); + } + }); + + return mockPort; + } +} diff --git a/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts b/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts new file mode 100644 index 00000000..8dfee50a --- /dev/null +++ b/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts @@ -0,0 +1,190 @@ +import type { SerialPortMock } from 'serialport'; + +type MockPortBinding = NonNullable['port']>; + +export interface SlvCtrlPlusDeviceSimulatorConfig { + deviceType?: string; + fwVersion?: number; + protocolVersion?: number; + protocol?: 'v1' | 'legacy'; +} + +/** + * Simulates a SlvCtrl+ device backed by a MockPortBinding. + * + * Supports both the V1 protocol and the legacy ( = { + connected: '1', + enabled: '0', + counter: '42', + level: '5', + temperature: '36.6', + gain: '0.5', + label: 'TestDevice', + mode: 'manual', + intensity: '0', + preset: 'low', + channel: '1', + }; + + public constructor(config: SlvCtrlPlusDeviceSimulatorConfig = {}) { + this.deviceType = config.deviceType ?? 'testDevice'; + this.fwVersion = config.fwVersion ?? 1; + this.protocol = config.protocol ?? 'v1'; + // Legacy protocol version defaults to 0 to signal pre-V1; V1 defaults to 1 + this.protocolVersion = config.protocolVersion ?? (this.protocol === 'legacy' ? 0 : 1); + } + + /** Wire the simulator to a MockPortBinding once the port is open. */ + public attachToPort(bindingPort: MockPortBinding): void { + this.port = bindingPort; + const originalWrite = bindingPort.write.bind(bindingPort); + + bindingPort.write = async (buffer: Buffer): Promise => { + await originalWrite(buffer); + + const command = buffer.toString('utf-8').trimEnd(); + const response = this.respond(command); + + if (null !== response) { + // Use setImmediate so the response lands after writeAndExpect sets up its + // data listener (the listener is registered before write() returns). + setImmediate(() => { + bindingPort.emitData(Buffer.from(response + '\n', 'utf-8')); + }); + } + }; + } + + /** Simulate the hardware being unplugged by closing the port binding. */ + public disconnect(): Promise { + if (this.port === null) { + return Promise.resolve(); + } + return this.port.close(); + } + + public getValue(name: string): string | undefined { + return this.values[name]; + } + + public setValue(name: string, value: string): void { + this.values[name] = value; + } + + private get attributeDefs(): string[] { + return this.protocol === 'legacy' ? this.legacyAttributeDefs : this.v1AttributeDefs; + } + + private respond(command: string): string | null { + if (command === 'clear') { + return this.protocol === 'legacy' ? 'clear;;ok' : 'clear;;status:ok'; + } + + if (command === 'introduce') { + if (this.protocol === 'legacy') { + // Legacy format: positional, no status suffix — detected by the device factory + // via the regex /^introduce;([^,;]+),(\d+),(\d+)$/ + return `introduce;${this.deviceType},${this.fwVersion},${this.protocolVersion}`; + } + return `introduce;type:${this.deviceType},fw:${this.fwVersion},protocol:${this.protocolVersion};status:ok`; + } + + if (command === 'attributes') { + const attrStr = this.attributeDefs.join(','); + return this.protocol === 'legacy' + ? `attributes;${attrStr};ok` + : `attributes;${attrStr};status:ok`; + } + + if (command === 'status') { + const statusStr = Object.entries(this.values) + .map(([k, v]) => `${k}:${v}`) + .join(','); + return this.protocol === 'legacy' + ? `status;${statusStr};ok` + : `status;${statusStr};status:ok`; + } + + // V1 set command: "set level 5" + if (this.protocol === 'v1' && command.startsWith('set ')) { + const parts = command.split(' '); + const attrName = parts[1]; + const value = parts.slice(2).join(' '); + if (undefined !== attrName) { + this.values[attrName] = value; + } + return `${command};value:${value};status:ok`; + } + + // Legacy set command: "set-level 5" + if (this.protocol === 'legacy' && command.startsWith('set-')) { + const withoutPrefix = command.slice(4); // strip "set-" + const spaceIdx = withoutPrefix.indexOf(' '); + const attrName = spaceIdx >= 0 ? withoutPrefix.slice(0, spaceIdx) : withoutPrefix; + const value = spaceIdx >= 0 ? withoutPrefix.slice(spaceIdx + 1) : ''; + if (attrName) { + this.values[attrName] = value; + } + // Legacy result suffix is plain "ok", not "status:ok" + return `${command};${value};ok`; + } + + return null; + } +} diff --git a/tests/integration/helpers/zc95DeviceSimulator.ts b/tests/integration/helpers/zc95DeviceSimulator.ts new file mode 100644 index 00000000..b4cb8326 --- /dev/null +++ b/tests/integration/helpers/zc95DeviceSimulator.ts @@ -0,0 +1,179 @@ +import type { SerialPortMock } from 'serialport'; + +type MockPortBinding = NonNullable['port']>; + +const STX = 0x02; +const ETX = 0x03; +const EOT = 0x04; + +interface Zc95SimulatorPattern { + id: number; + name: string; + menuItems?: Array<{ + Id: number; + Title: string; + Group?: number; + Type: 'MIN_MAX' | 'MULTI_CHOICE'; + Default: number; + Min?: number; + Max?: number; + IncrementStep?: number; + UoM?: string; + Choices?: Array<{ Id: number; Name: string }>; + }>; +} + +export class Zc95DeviceSimulator { + private port: MockPortBinding | null = null; + + private readonly fwVersion: string; + private readonly patterns: Zc95SimulatorPattern[]; + + public readonly receivedCommands: Array<{ type: string; msgId: number; data: Record }> = []; + + public constructor(config?: { fwVersion?: string; patterns?: Zc95SimulatorPattern[] }) { + this.fwVersion = config?.fwVersion ?? 'test-v1.0'; + this.patterns = config?.patterns ?? [{ id: 0, name: 'Test Pattern', menuItems: [] }]; + } + + public attachToPort(bindingPort: MockPortBinding): void { + this.port = bindingPort; + const originalWrite = bindingPort.write.bind(bindingPort); + + bindingPort.write = async (buffer: Buffer): Promise => { + await originalWrite(buffer); + + if (buffer.length === 1 && buffer[0] === EOT) { + return; + } + + const stxIdx = buffer.indexOf(STX); + const etxIdx = buffer.lastIndexOf(ETX); + + if (stxIdx < 0 || etxIdx <= stxIdx) { + return; + } + + const jsonStr = buffer.toString('utf-8', stxIdx + 1, etxIdx); + let msg: { Type: string; MsgId: number }; + try { + msg = JSON.parse(jsonStr); + } catch { + return; + } + + this.receivedCommands.push({ type: msg.Type, msgId: msg.MsgId, data: msg as unknown as Record }); + + const response = this.handleMessage(msg); + if (null !== response) { + const framed = Buffer.concat([ + Buffer.from([STX]), + Buffer.from(JSON.stringify(response), 'utf-8'), + Buffer.from([ETX]), + ]); + setImmediate(() => { + bindingPort.emitData(framed); + }); + } + }; + } + + public disconnect(): Promise { + if (this.port === null) { + return Promise.resolve(); + } + return this.port.close(); + } + + /** + * Emit an unsolicited PowerStatus message, mimicking the ZC95 firmware's behaviour of + * periodically broadcasting channel power readings without a corresponding request. + * + * The device handles this in `processPowerStatusMessage` (MsgId === -1, Type === 'PowerStatus'), + * updates the powerChannel attributes and calls `updateLastRefresh()`, which emits + * `DeviceEvent.deviceRefreshed`. + * + * Power values are in raw units (0–1000); the device converts them to percentages via * 0.1. + */ + public sendPowerStatus(channels: Array<{ + channel: number; + outputPower: number; + maxOutputPower: number; + powerLimit: number; + }>): void { + if (this.port === null) { + return; + } + + const msg = { + Type: 'PowerStatus', + MsgId: -1, + Result: 'OK', + Channels: channels.map(c => ({ + Channel: c.channel, + OutputPower: c.outputPower, + MaxOutputPower: c.maxOutputPower, + PowerLimit: c.powerLimit, + })), + }; + + const framed = Buffer.concat([ + Buffer.from([STX]), + Buffer.from(JSON.stringify(msg), 'utf-8'), + Buffer.from([ETX]), + ]); + + this.port.emitData(framed); + } + + private handleMessage(msg: { Type: string; MsgId: number }): Record | null { + switch (msg.Type) { + case 'GetVersion': + return { + Type: 'VersionDetails', + MsgId: msg.MsgId, + Result: 'OK', + ZC95: this.fwVersion, + WsMajor: 1, + WsMinor: 0, + }; + + case 'GetPatterns': + return { + Type: 'PatternList', + MsgId: msg.MsgId, + Result: 'OK', + Patterns: this.patterns.map(p => ({ Id: p.id, Name: p.name })), + }; + + case 'GetPatternDetail': { + const id = String((msg as Record).Id); + const pattern = this.patterns.find(p => String(p.id) === id) ?? this.patterns[0]; + return { + Type: 'PatternDetail', + MsgId: msg.MsgId, + Result: 'OK', + Name: pattern.name, + Id: pattern.id, + ButtonA: '', + MenuItems: pattern.menuItems ?? [], + }; + } + + case 'PatternStart': + case 'PatternStop': + case 'PatternMinMaxChange': + case 'PatternMultiChoiceChange': + case 'SetPower': + case 'PatternSoftButton': + return { + Type: 'Ack', + MsgId: msg.MsgId, + Result: 'OK', + }; + + default: + return null; + } + } +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 00000000..9c7dae17 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": [ + "../src/**/*", + "./**/*" + ] +} diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts new file mode 100644 index 00000000..638ffdcf --- /dev/null +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -0,0 +1,455 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { EventEmitter } from 'events'; +import { tmpdir } from 'os'; +import ScriptRuntime, { SupportedDeviceEvent } from '../../../src/automation/scriptRuntime.js'; +import AutomationEventType from '../../../src/automation/automationEventType.js'; +import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; +import Device, { DeviceAttributes, ExtractAttributeValue } from '../../../src/device/device.js'; +import { DeviceAttributeModifier } from '../../../src/device/attribute/deviceAttribute.js'; +import DeviceRepositoryInterface from '../../../src/repository/deviceRepositoryInterface.js'; +import StrDeviceAttribute from '../../../src/device/attribute/strDeviceAttribute.js'; +import Logger from '../../../src/logging/Logger.js'; + +// --------------------------------------------------------------------------- +// Stub device with a single 'label' string attribute +// --------------------------------------------------------------------------- + +class StubDevice extends Device { + public readonly setAttributeCalls: Array<[string, unknown]> = []; + + public constructor(id: string, name: string) { + super( + id, name, 'test', new Date(), true, + { + label: StrDeviceAttribute.createInitialized( + 'label', undefined, DeviceAttributeModifier.readWrite, 'hello' + ), + }, + {}, new EventEmitter(), + ); + } + + public setAttribute< + K extends keyof DeviceAttributes & string, + V extends ExtractAttributeValue + >(attributeName: K, value: V): Promise { + this.setAttributeCalls.push([attributeName, value]); + return Promise.resolve(value); + } +} + +// --------------------------------------------------------------------------- +// Stub repository +// --------------------------------------------------------------------------- + +class StubRepo implements DeviceRepositoryInterface { + private readonly _devices: StubDevice[]; + + public constructor(devices: StubDevice[]) { + this._devices = devices; + } + + public getAll(): Device[] { + return this._devices; + } + + public getById(id: string): Device | null { + return this._devices.find(d => d.getDeviceId === id) ?? null; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Dispatch an event into the runtime and collect console.log lines until the + * given `marker` string appears (the script must emit it last). + */ +function dispatchAndCollect( + eventEmitter: EventEmitter, + runtime: ScriptRuntime, + device: StubDevice, + marker: string, + eventType: SupportedDeviceEvent['type'] = DeviceManagerEvent.deviceConnected, + timeoutMs = 5_000, +): Promise { + return new Promise((resolve, reject) => { + const logs: string[] = []; + const timer = setTimeout( + () => reject(new Error(`Timeout waiting for marker "${marker}". Got: ${JSON.stringify(logs)}`)), + timeoutMs, + ); + eventEmitter.on(AutomationEventType.consoleLog, (msg: string) => { + logs.push(msg); + if (msg === marker) { + clearTimeout(timer); + resolve(logs); + } + }); + runtime.runForEvent(eventType, device); + }); +} + +const TEST_END_MARKER = 'DONE'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ScriptRuntime (isolated-vm)', () => { + let runtime: ScriptRuntime; + let eventEmitter: EventEmitter; + let deviceA: StubDevice; + let deviceB: StubDevice; + let repo: StubRepo; + + beforeEach(() => { + eventEmitter = new EventEmitter(); + deviceA = new StubDevice('device-a', 'Device A'); + deviceB = new StubDevice('device-b', 'Device B'); + repo = new StubRepo([deviceA, deviceB]); + const logger = mock(); + logger.child.mockReturnValue(mock()); + runtime = new ScriptRuntime(repo, tmpdir(), eventEmitter, logger); + }); + + afterEach(async () => { + if (runtime.isRunning()) { + await runtime.stop(); + } + }); + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + it('emits scriptStarted on load and scriptStopped on stop', async () => { + const events: string[] = []; + eventEmitter.on(AutomationEventType.scriptStarted, () => events.push('started')); + eventEmitter.on(AutomationEventType.scriptStopped, () => events.push('stopped')); + + await runtime.load('onEvent(() => {});'); + + expect(events).toEqual(['started']); + expect(runtime.isRunning()).toBe(true); + expect(runtime.getRunningSince()).toBeInstanceOf(Date); + + await runtime.stop(); + + expect(events).toEqual(['started', 'stopped']); + expect(runtime.isRunning()).toBe(false); + expect(runtime.getRunningSince()).toBeNull(); + }); + + it('runForEvent does nothing when not loaded', () => { + // Should not throw + runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceA); + }); + + it('onStart handler runs before scriptStarted', async () => { + const logs: string[] = []; + eventEmitter.on(AutomationEventType.consoleLog, (msg: string) => logs.push(msg)); + + await runtime.load(` + onStart(async () => { + console.log('start-called'); + }); + onEvent(() => {}); + `); + + expect(logs).toContain('start-called'); + }); + + it('onStop handler runs on stop', async () => { + await runtime.load(` + onStop(async () => { + console.log('stop-called'); + }); + onEvent(() => {}); + `); + + const logs: string[] = []; + eventEmitter.on(AutomationEventType.consoleLog, (msg: string) => logs.push(msg)); + + await runtime.stop(); + + expect(logs).toContain('stop-called'); + }); + + // ----------------------------------------------------------------------- + // console.log + // ----------------------------------------------------------------------- + + it('console.log emits consoleLog event', async () => { + await runtime.load(` + onEvent(async () => { + console.log('hello world'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, 'hello world'); + expect(logs).toContain('hello world'); + }); + + // ----------------------------------------------------------------------- + // event.type + // ----------------------------------------------------------------------- + + it('event.type is the correct string', async () => { + await runtime.load(` + onEvent(async (event) => { + console.log(event.type); + }); + `); + + const logs = await dispatchAndCollect( + eventEmitter, runtime, deviceA, DeviceManagerEvent.deviceConnected, + ); + expect(logs).toContain('deviceConnected'); + }); + + it('event.type reflects the dispatched event type', async () => { + await runtime.load(` + onEvent(async (event) => { + console.log(event.type); + }); + `); + + const logs = await dispatchAndCollect( + eventEmitter, runtime, deviceA, DeviceManagerEvent.deviceDisconnected, + DeviceManagerEvent.deviceDisconnected, + ); + expect(logs).toContain('deviceDisconnected'); + }); + + // ----------------------------------------------------------------------- + // event.device identity + // ----------------------------------------------------------------------- + + it('event.device.getDeviceId and getDeviceName are correct', async () => { + await runtime.load(` + onEvent(async (event) => { + console.log(event.device.getDeviceId); + console.log(event.device.getDeviceName); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs).toContain('device-a'); + expect(logs).toContain('Device A'); + }); + + // ----------------------------------------------------------------------- + // event.device.getAttribute + // ----------------------------------------------------------------------- + + it('event.device.getAttribute returns { value } for an existing attribute', async () => { + await runtime.load(` + onEvent(async (event) => { + const attr = await event.device.getAttribute('label'); + console.log(attr !== undefined ? attr.value : 'undefined'); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs).toContain('hello'); + }); + + it('event.device.getAttribute returns undefined for a missing attribute', async () => { + await runtime.load(` + onEvent(async (event) => { + const attr = await event.device.getAttribute('nonexistent'); + console.log(String(attr)); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs).toContain('undefined'); + }); + + // ----------------------------------------------------------------------- + // event.device.setAttribute + // ----------------------------------------------------------------------- + + it('event.device.setAttribute calls through to the host device', async () => { + await runtime.load(` + onEvent(async (event) => { + await event.device.setAttribute('label', 'new-value'); + console.log('${TEST_END_MARKER}'); + }); + `); + + await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(deviceA.setAttributeCalls).toEqual([['label', 'new-value']]); + }); + + // ----------------------------------------------------------------------- + // devices.getById + // ----------------------------------------------------------------------- + + it('devices.getById returns a proxy for a known device', async () => { + await runtime.load(` + onEvent(async (event) => { + const d = devices.getById('device-b'); + console.log(d !== null ? d.getDeviceId : 'null'); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs).toContain('device-b'); + }); + + it('devices.getById returns null for an unknown device', async () => { + await runtime.load(` + onEvent(async (event) => { + const d = devices.getById('ghost'); + console.log(d === null ? 'null' : 'not-null'); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs).toContain('null'); + }); + + it('devices.getById proxy can setAttribute on a different device', async () => { + await runtime.load(` + onEvent(async (event) => { + const d = devices.getById('device-b'); + await d.setAttribute('label', 'from-script'); + console.log('${TEST_END_MARKER}'); + }); + `); + + await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(deviceB.setAttributeCalls).toEqual([['label', 'from-script']]); + }); + + // ----------------------------------------------------------------------- + // devices.getAll + // ----------------------------------------------------------------------- + + it('devices.getAll returns proxies for all devices', async () => { + await runtime.load(` + onEvent(async (event) => { + const all = devices.getAll(); + console.log(String(all.length)); + all.forEach(d => console.log(d.getDeviceId)); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs).toContain('2'); + expect(logs).toContain('device-a'); + expect(logs).toContain('device-b'); + }); + + // ----------------------------------------------------------------------- + // devices.getById with getAttribute + // ----------------------------------------------------------------------- + + it('devices.getById getAttribute returns the attribute value', async () => { + await runtime.load(` + onEvent(async (event) => { + const d = devices.getById('device-a'); + const attr = await d.getAttribute('label'); + console.log(String(attr !== undefined ? attr.value : null)); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs).toContain('hello'); + }); + + it('devices.getById getAttribute returns null for an unknown device', async () => { + await runtime.load(` + onEvent(async (event) => { + const d = devices.getById('ghost'); + const attr = d !== null ? await d.getAttribute('label') : undefined; + console.log(String(attr !== undefined ? attr.value : null)); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs).toContain('null'); + }); + + // ----------------------------------------------------------------------- + // devices.getById with setAttribute + // ----------------------------------------------------------------------- + + it('devices.getById setAttribute calls through to the host device', async () => { + await runtime.load(` + onEvent(async (event) => { + await devices.getById('device-b').setAttribute('label', 'updated'); + console.log('${TEST_END_MARKER}'); + }); + `); + + await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(deviceB.setAttributeCalls).toEqual([['label', 'updated']]); + }); + + it('devices.getById setAttribute on unknown device does not throw', async () => { + await runtime.load(` + onEvent(async (event) => { + const d = devices.getById('ghost'); + if (d !== null) await d.setAttribute('label', 'x'); + console.log('${TEST_END_MARKER}'); + }); + `); + + // Should complete without error + await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + }); + + // ----------------------------------------------------------------------- + // Event queue ordering + // ----------------------------------------------------------------------- + + it('processes multiple events in order', async () => { + await runtime.load(` + onEvent(async (event) => { + console.log(event.device.getDeviceId); + }); + `); + + const received: string[] = []; + const allDone = new Promise((resolve) => { + let count = 0; + eventEmitter.on(AutomationEventType.consoleLog, (msg: string) => { + received.push(msg); + if (++count >= 2) resolve(); + }); + }); + + runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceA); + runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceB); + + await allDone; + expect(received).toEqual(['device-a', 'device-b']); + }); + + // ----------------------------------------------------------------------- + // Script without onEvent handler (fire-and-forget top-level code) + // ----------------------------------------------------------------------- + + it('dispatching an event with no onEvent handler does nothing', async () => { + await runtime.load('// no onEvent registered'); + + // Should not throw or hang + runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceA); + + // Give the queue a moment to process + await new Promise(resolve => setTimeout(resolve, 100)); + }); +}); diff --git a/tests/unit/controller/healthController.spec.ts b/tests/unit/controller/healthController.spec.ts new file mode 100644 index 00000000..65e1e1d7 --- /dev/null +++ b/tests/unit/controller/healthController.spec.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import HealthController from '../../../src/controller/healthController.js'; +import HealthMetricsCollector from '../../../src/health/healthMetricsCollector.js'; +import { SerializedHealthMetrics } from '../../../src/health/serializedTypes.js'; + +describe('HealthController', () => { + it('returns 204 when no metrics have been collected yet', () => { + const collector = mock(); + collector.collect.mockReturnValue(null); + + const req = mock(); + const res = mock(); + res.sendStatus.mockReturnValue(res); + + new HealthController(collector).execute(req, res); + + expect(res.sendStatus).toHaveBeenCalledWith(204); + expect(res.json).not.toHaveBeenCalled(); + }); + + it('returns 200 with metrics once collection has run', () => { + const metrics = mock(); + const collector = mock(); + collector.collect.mockReturnValue(metrics); + + const req = mock(); + const res = mock(); + res.json.mockReturnValue(res); + + new HealthController(collector).execute(req, res); + + expect(res.json).toHaveBeenCalledWith(metrics); + expect(res.sendStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index 6293c83d..bf73271c 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -10,8 +10,8 @@ import {DeviceAttributeModifier} from "../../../../../src/device/attribute/devic import {Int} from "../../../../../src/util/numbers.js"; import {describe, it, expect} from "vitest"; import {mock} from "vitest-mock-extended"; -import { EventEmitter } from 'events'; -import { DeviceId } from '../../../../../src/device/deviceId.js'; +import {EventEmitter} from 'events'; +import {DeviceId} from '../../../../../src/device/deviceId.js'; describe('ButtplugIoDevice', () => { diff --git a/tests/unit/device/protocol/virtual/display/displayVirtualDevice.spec.ts b/tests/unit/device/protocol/virtual/display/displayVirtualDevice.spec.ts index 20bf94ea..5f5e9e9f 100644 --- a/tests/unit/device/protocol/virtual/display/displayVirtualDevice.spec.ts +++ b/tests/unit/device/protocol/virtual/display/displayVirtualDevice.spec.ts @@ -1,12 +1,15 @@ import {describe, it, expect} from "vitest"; +import {mock} from "vitest-mock-extended"; +import {EventEmitter} from "events"; import VirtualDevice from "../../../../../../src/device/protocol/virtual/virtualDevice.js"; import DisplayVirtualDeviceLogic from "../../../../../../src/device/protocol/virtual/display/displayVirtualDeviceLogic.js"; +import Logger from "../../../../../../src/logging/Logger.js"; describe('DisplayVirtualDevice', () => { - function createDevice(): VirtualDevice { - const virtualDeviceLogic = new DisplayVirtualDeviceLogic(); + function createDevice(): VirtualDevice { + const virtualDeviceLogic = new DisplayVirtualDeviceLogic({}); return new VirtualDevice( '1.0.0', 'device-id', @@ -16,6 +19,8 @@ describe('DisplayVirtualDevice', () => { new Date(), {}, virtualDeviceLogic, + new EventEmitter(), + mock(), ); } diff --git a/tests/unit/device/testDevice.ts b/tests/unit/device/testDevice.ts index 30b782b3..0a5e8ddf 100644 --- a/tests/unit/device/testDevice.ts +++ b/tests/unit/device/testDevice.ts @@ -1,10 +1,12 @@ import { EventEmitter } from "events"; -import Device, {ExtractAttributeValue, DeviceAttributes} from "../../../src/device/device.js"; +import Device, {DeviceAttributes} from "../../../src/device/device.js"; +import ExtractAttributeValue from "../../../src/device/device.js"; +import { DeviceId } from '../../../src/device/deviceId.js'; export default class TestDevice extends Device { public constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, connectedSince: Date, controllable: boolean, @@ -22,5 +24,5 @@ export default class TestDevice extends Device } export const createTestDevice = (): TestDevice => { - return new TestDevice('foo', 'Foo', new Date(), false, new EventEmitter()); + return new TestDevice(DeviceId.create('foo'), 'Foo', new Date(), false, new EventEmitter()); } diff --git a/tests/unit/device/testDeviceProvider.ts b/tests/unit/device/testDeviceProvider.ts index b421744a..712baae1 100644 --- a/tests/unit/device/testDeviceProvider.ts +++ b/tests/unit/device/testDeviceProvider.ts @@ -1,15 +1,16 @@ import {EventEmitter} from "events"; import DeviceProvider from "../../../src/device/provider/deviceProvider.js"; import Logger from "../../../src/logging/Logger.js"; +import DeviceManager from "../../../src/device/deviceManager.js"; export default class TestDeviceProvider extends DeviceProvider { - public constructor(eventEmitter: EventEmitter, logger: Logger) + public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { - super(eventEmitter, logger); + super(deviceManager, eventEmitter, logger); } - public init(): Promise + public override init(): Promise { // noop return new Promise((resolve) => resolve()); diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts index 0a29bc17..0992fa36 100644 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -62,7 +62,7 @@ describe('SerialPortObserver', () => { vi.spyOn(SerialPort, 'list').mockResolvedValue([]); const observer = createObserver(); - await observer.init(); + await observer.start(); expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); }); @@ -73,7 +73,7 @@ describe('SerialPortObserver', () => { ]); const observer = createObserver(); - await observer.init(); + await observer.start(); expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); }); @@ -84,7 +84,7 @@ describe('SerialPortObserver', () => { ]); const observer = createObserver(); - await observer.init(); + await observer.start(); expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); }); @@ -94,7 +94,7 @@ describe('SerialPortObserver', () => { vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); const observer = createObserver(); - await observer.init(); + await observer.start(); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( @@ -107,7 +107,7 @@ describe('SerialPortObserver', () => { vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); const observer = createObserver(); - await observer.init(); + await observer.start(); const expectedSn = 'serial-0403-6001-port1'; expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( @@ -120,7 +120,7 @@ describe('SerialPortObserver', () => { vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); const observer = createObserver(); - await observer.init(); + await observer.start(); await vi.advanceTimersByTimeAsync(3000); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); @@ -133,7 +133,7 @@ describe('SerialPortObserver', () => { .mockResolvedValueOnce([]); const observer = createObserver(); - await observer.init(); + await observer.start(); await vi.advanceTimersByTimeAsync(3000); expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledOnce(); @@ -150,7 +150,7 @@ describe('SerialPortObserver', () => { .mockResolvedValueOnce([port]); // third run: back const observer = createObserver(); - await observer.init(); + await observer.start(); await vi.advanceTimersByTimeAsync(3000); await vi.advanceTimersByTimeAsync(3000); @@ -161,7 +161,7 @@ describe('SerialPortObserver', () => { vi.spyOn(SerialPort, 'list').mockRejectedValue(new Error('USB stack error')); const observer = createObserver(); - await expect(observer.init()).resolves.not.toThrow(); + await expect(observer.start()).resolves.not.toThrow(); expect(mockLogger.error).toHaveBeenCalled(); }); @@ -171,7 +171,7 @@ describe('SerialPortObserver', () => { vi.spyOn(SerialPort, 'list').mockResolvedValue([port1, port2]); const observer = createObserver(); - await observer.init(); + await observer.start(); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( diff --git a/tsconfig.json b/tsconfig.json index 8e734aeb..ed6dffe3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "strict": true, "resolveJsonModule": true, "esModuleInterop": true, - "outDir": "dist", + "rootDir": "./src", + "outDir": "./dist", "paths": { "@serialport/bindings-interface": [ "./node_modules/@serialport/bindings-interface/dist/index.d.ts" diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts new file mode 100644 index 00000000..7c052e66 --- /dev/null +++ b/vitest.config.integration.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./tests/vitest.setup.ts"], + include: ["tests/integration/**/*.spec.ts"], + testTimeout: 10000, + hookTimeout: 10000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 96a3abbd..f80b7830 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: "node", setupFiles: ["./tests/vitest.setup.ts"], + include: ["tests/unit/**/*.spec.ts"], coverage: { provider: "v8", reporter: ["text", "html", "lcov"], From aa0fb912e152113513d06f79d106baf13fd11877 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 27 Jun 2026 15:30:26 +0200 Subject: [PATCH 29/42] Revert back to string for deviceId --- src/controller/getDeviceController.ts | 2 +- src/controller/patchDeviceController.ts | 2 +- src/device/deviceManager.ts | 10 +++++----- src/repository/connectedDeviceRepository.ts | 2 +- src/socket/deviceUpdateHandler.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/controller/getDeviceController.ts b/src/controller/getDeviceController.ts index 94814d4e..32ef19bd 100644 --- a/src/controller/getDeviceController.ts +++ b/src/controller/getDeviceController.ts @@ -22,7 +22,7 @@ export default class GetDeviceController implements ControllerInterface public execute(req: GetDeviceRequest, res: Response): void { const { deviceId } = req.params; - const device = this.connectedDeviceRepository.getById(DeviceId.create(deviceId)); + const device = this.connectedDeviceRepository.getById(deviceId); if (null === device) { res.sendStatus(404); diff --git a/src/controller/patchDeviceController.ts b/src/controller/patchDeviceController.ts index c88b6509..eb8dcabb 100644 --- a/src/controller/patchDeviceController.ts +++ b/src/controller/patchDeviceController.ts @@ -23,7 +23,7 @@ export default class PatchDeviceController implements ControllerInterface public async execute(req: PatchDeviceRequest, res: Response): Promise { const { deviceId } = req.params; - const device = this.connectedDeviceRepository.getById(DeviceId.create(deviceId)); + const device = this.connectedDeviceRepository.getById(deviceId); if (null === device) { res.sendStatus(404); diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 49454709..bcb361de 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -36,11 +36,11 @@ export default class DeviceManager private readonly logger: Logger; - private readonly detectedDeviceAcquireQueue: Map void }[]> = new Map(); + private readonly detectedDeviceAcquireQueue: Map void }[]> = new Map(); - private readonly connectedDevices: Map>; + private readonly connectedDevices: Map>; - public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { + public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { this.eventEmitter = eventEmitter; this.logger = logger.child({ name: DeviceManager.name }); this.connectedDevices = connectedDevices; @@ -138,7 +138,7 @@ export default class DeviceManager return Array.from(this.connectedDevices.values()); } - public getConnectedDevice(deviceId: DeviceId): Device|null + public getConnectedDevice(deviceId: string): Device|null { const device = this.connectedDevices.get(deviceId); @@ -172,7 +172,7 @@ export default class DeviceManager } } - private clearDetectedDeviceAcquireQueue(deviceId: DeviceId, reason: string): void + private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void { for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { entry.resolve({ successful: false, reason }); diff --git a/src/repository/connectedDeviceRepository.ts b/src/repository/connectedDeviceRepository.ts index 5ad090fe..15486a9e 100644 --- a/src/repository/connectedDeviceRepository.ts +++ b/src/repository/connectedDeviceRepository.ts @@ -16,7 +16,7 @@ export default class ConnectedDeviceRepository implements DeviceRepositoryInterf return this.deviceManager.getConnectedDevices(); } - public getById(uuid: DeviceId): Device | null { + public getById(uuid: string): Device | null { return this.deviceManager.getConnectedDevice(uuid); } } diff --git a/src/socket/deviceUpdateHandler.ts b/src/socket/deviceUpdateHandler.ts index 0bdc760d..2cb1d7f3 100644 --- a/src/socket/deviceUpdateHandler.ts +++ b/src/socket/deviceUpdateHandler.ts @@ -25,7 +25,7 @@ export default class DeviceUpdateHandler public async handle(data: DeviceUpdateData): Promise { const deviceId = data.deviceId; - const device = this.connectedDeviceRepository.getById(DeviceId.create(deviceId)); + const device = this.connectedDeviceRepository.getById(deviceId); if (null === device) { return; From ba01d725bed0248115c59d9f1d3add125db8ac8b Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 27 Jun 2026 17:20:12 +0200 Subject: [PATCH 30/42] Finish merging main --- src/controller/getDeviceController.ts | 2 +- src/controller/patchDeviceController.ts | 2 +- src/repository/connectedDeviceRepository.ts | 2 +- src/socket/deviceUpdateHandler.ts | 1 - src/socket/types.ts | 3 +- tests/integration/deviceEvents.spec.ts | 2 +- tests/integration/helpers/appHelper.ts | 13 +++---- tests/unit/automation/scriptRuntime.spec.ts | 36 +++++++++---------- tests/unit/device/deviceManager.spec.ts | 16 ++++----- .../display/displayVirtualDevice.spec.ts | 3 +- tests/unit/device/testDevice.ts | 10 +++--- .../transport/serialPortObserver.spec.ts | 7 ++-- 12 files changed, 49 insertions(+), 48 deletions(-) diff --git a/src/controller/getDeviceController.ts b/src/controller/getDeviceController.ts index 32ef19bd..b6b3ecb8 100644 --- a/src/controller/getDeviceController.ts +++ b/src/controller/getDeviceController.ts @@ -5,7 +5,7 @@ import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.j import DeviceDiscriminator from '../serialization/discriminator/deviceDiscriminator.js'; import { DeviceId } from '../device/deviceId.js'; -type GetDeviceRequest = Request<{ deviceId: string }>; +type GetDeviceRequest = Request<{ deviceId: DeviceId }>; export default class GetDeviceController implements ControllerInterface { diff --git a/src/controller/patchDeviceController.ts b/src/controller/patchDeviceController.ts index eb8dcabb..558593fe 100644 --- a/src/controller/patchDeviceController.ts +++ b/src/controller/patchDeviceController.ts @@ -6,7 +6,7 @@ import DeviceUpdaterInterface from '../device/updater/deviceUpdaterInterface.js' import { DeviceData } from '../device/device.js'; import { DeviceId } from '../device/deviceId.js'; -type PatchDeviceRequest = Request<{ deviceId: string }, any, DeviceData>; +type PatchDeviceRequest = Request<{ deviceId: DeviceId }, any, DeviceData>; export default class PatchDeviceController implements ControllerInterface { diff --git a/src/repository/connectedDeviceRepository.ts b/src/repository/connectedDeviceRepository.ts index 15486a9e..5ad090fe 100644 --- a/src/repository/connectedDeviceRepository.ts +++ b/src/repository/connectedDeviceRepository.ts @@ -16,7 +16,7 @@ export default class ConnectedDeviceRepository implements DeviceRepositoryInterf return this.deviceManager.getConnectedDevices(); } - public getById(uuid: string): Device | null { + public getById(uuid: DeviceId): Device | null { return this.deviceManager.getConnectedDevice(uuid); } } diff --git a/src/socket/deviceUpdateHandler.ts b/src/socket/deviceUpdateHandler.ts index 2cb1d7f3..ba316983 100644 --- a/src/socket/deviceUpdateHandler.ts +++ b/src/socket/deviceUpdateHandler.ts @@ -3,7 +3,6 @@ import DeviceUpdaterInterface from '../device/updater/deviceUpdaterInterface.js' import { DeviceUpdateData } from './types.js'; import Logger from '../logging/Logger.js'; import { logError } from '../util/error.js'; -import { DeviceId } from '../device/deviceId.js'; export default class DeviceUpdateHandler { diff --git a/src/socket/types.ts b/src/socket/types.ts index eabc32bc..a71b4b48 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -6,8 +6,9 @@ import AutomationEventType from '../automation/automationEventType.js'; import { SerializedHealthMetrics } from '../health/serializedTypes.js'; import { SerializedDevice } from '../device/serializedTypes.js'; import { SerializedSettings } from '../settings/serializedTypes.js'; +import { DeviceId } from '../device/deviceId.js'; -export type DeviceUpdateData = { deviceId: string, data: DeviceData } +export type DeviceUpdateData = { deviceId: DeviceId, data: DeviceData } export interface ClientToServerEvents { [WebSocketEvent.deviceUpdateReceived]: (data: DeviceUpdateData) => void; diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts index 7943a36c..60f08456 100644 --- a/tests/integration/deviceEvents.spec.ts +++ b/tests/integration/deviceEvents.spec.ts @@ -118,7 +118,7 @@ describe('Device events', () => { const settingsWithOneDevice = new Settings(); settingsWithOneDevice.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); - settingsWithOneDevice.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); + settingsWithOneDevice.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); settingsManager.replace(settingsWithOneDevice); await deviceDisconnected; diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index 6a6bca72..f6e251a7 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -15,14 +15,15 @@ import { Container } from '@timesplinter/pimple'; import MockSerialPortFactory from './mockSerialPortFactory.js'; import http from 'http'; import { AddressInfo } from 'net'; +import { DeviceId } from '../../../src/device/deviceId.js'; process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; -export const TEST_DEVICE_ID = 'a1b2c3d4-1234-4321-abcd-ef1234567890'; +export const TEST_DEVICE_ID = DeviceId.create('a1b2c3d4-1234-4321-abcd-ef1234567890'); export const TEST_SOURCE_ID = 'b2c3d4e5-2345-4321-abcd-ef1234567891'; -export const NEW_DEVICE_ID = 'c3d4e5f6-3456-4321-abcd-ef1234567892'; +export const NEW_DEVICE_ID = DeviceId.create('c3d4e5f6-3456-4321-abcd-ef1234567892'); -export type DeviceSpec = { id: string, name: string, config?: { min: number, max: number } }; +export type DeviceSpec = { id: DeviceId, name: string, config?: { min: number, max: number } }; function makeBaseSettings(): Settings { const settings = new Settings(); @@ -125,7 +126,7 @@ export const resetTestApp = async (app: TestApp): Promise => { }, 1000, ); - const listener = (device: { getDeviceId: string }) => { + const listener = (device: { getDeviceId: DeviceId }) => { remaining.delete(device.getDeviceId); if (remaining.size === 0) { cleanup(); @@ -219,7 +220,7 @@ export const connectDevices = (container: Container, specs: DeviceSp const pendingIds = new Set(specs.map(s => s.id)); const allConnected = new Promise((resolve, reject) => { - const listener = (device: { getDeviceId: string }) => { + const listener = (device: { getDeviceId: DeviceId }) => { pendingIds.delete(device.getDeviceId); if (pendingIds.size === 0) { deviceManager.off(DeviceManagerEvent.deviceConnected, listener); @@ -238,7 +239,7 @@ export const connectDevices = (container: Container, specs: DeviceSp const settings = settingsManager.load(); for (const spec of specs) { - settings.addKnownDevice(new KnownDevice(spec.id, spec.id, spec.name, 'randomGenerator', 'virtual', spec.config ?? { min: 0, max: 100 })); + settings.addKnownDevice(new KnownDevice(spec.id, spec.name, 'randomGenerator', 'virtual', spec.config ?? { min: 0, max: 100 })); } settingsManager.replace(settings); diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index 638ffdcf..5e7f1d90 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -5,11 +5,12 @@ import { tmpdir } from 'os'; import ScriptRuntime, { SupportedDeviceEvent } from '../../../src/automation/scriptRuntime.js'; import AutomationEventType from '../../../src/automation/automationEventType.js'; import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; -import Device, { DeviceAttributes, ExtractAttributeValue } from '../../../src/device/device.js'; +import Device, { AttributeKeyOf, AttributeValueOf, DeviceAttributes } from '../../../src/device/device.js'; import { DeviceAttributeModifier } from '../../../src/device/attribute/deviceAttribute.js'; import DeviceRepositoryInterface from '../../../src/repository/deviceRepositoryInterface.js'; import StrDeviceAttribute from '../../../src/device/attribute/strDeviceAttribute.js'; import Logger from '../../../src/logging/Logger.js'; +import { DeviceId } from '../../../src/device/deviceId.js'; // --------------------------------------------------------------------------- // Stub device with a single 'label' string attribute @@ -18,7 +19,7 @@ import Logger from '../../../src/logging/Logger.js'; class StubDevice extends Device { public readonly setAttributeCalls: Array<[string, unknown]> = []; - public constructor(id: string, name: string) { + public constructor(id: DeviceId, name: string) { super( id, name, 'test', new Date(), true, { @@ -30,10 +31,9 @@ class StubDevice extends Device { ); } - public setAttribute< - K extends keyof DeviceAttributes & string, - V extends ExtractAttributeValue - >(attributeName: K, value: V): Promise { + public async setAttribute< + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValueOf): Promise> { this.setAttributeCalls.push([attributeName, value]); return Promise.resolve(value); } @@ -107,8 +107,8 @@ describe('ScriptRuntime (isolated-vm)', () => { beforeEach(() => { eventEmitter = new EventEmitter(); - deviceA = new StubDevice('device-a', 'Device A'); - deviceB = new StubDevice('device-b', 'Device B'); + deviceA = new StubDevice(DeviceId.create('device-a'), 'Device A'); + deviceB = new StubDevice(DeviceId.create('device-b'), 'Device B'); repo = new StubRepo([deviceA, deviceB]); const logger = mock(); logger.child.mockReturnValue(mock()); @@ -238,8 +238,8 @@ describe('ScriptRuntime (isolated-vm)', () => { `); const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); - expect(logs).toContain('device-a'); - expect(logs).toContain('Device A'); + expect(logs).toContain(deviceA.getDeviceId); + expect(logs).toContain(deviceA.getDeviceName); }); // ----------------------------------------------------------------------- @@ -295,14 +295,14 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById returns a proxy for a known device', async () => { await runtime.load(` onEvent(async (event) => { - const d = devices.getById('device-b'); + const d = devices.getById('${deviceB.getDeviceId}'); console.log(d !== null ? d.getDeviceId : 'null'); console.log('${TEST_END_MARKER}'); }); `); const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); - expect(logs).toContain('device-b'); + expect(logs).toContain(deviceB.getDeviceId); }); it('devices.getById returns null for an unknown device', async () => { @@ -321,7 +321,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById proxy can setAttribute on a different device', async () => { await runtime.load(` onEvent(async (event) => { - const d = devices.getById('device-b'); + const d = devices.getById('${deviceB.getDeviceId}'); await d.setAttribute('label', 'from-script'); console.log('${TEST_END_MARKER}'); }); @@ -347,8 +347,8 @@ describe('ScriptRuntime (isolated-vm)', () => { const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); expect(logs).toContain('2'); - expect(logs).toContain('device-a'); - expect(logs).toContain('device-b'); + expect(logs).toContain(deviceA.getDeviceId); + expect(logs).toContain(deviceB.getDeviceId); }); // ----------------------------------------------------------------------- @@ -358,7 +358,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById getAttribute returns the attribute value', async () => { await runtime.load(` onEvent(async (event) => { - const d = devices.getById('device-a'); + const d = devices.getById('${deviceA.getDeviceId}'); const attr = await d.getAttribute('label'); console.log(String(attr !== undefined ? attr.value : null)); console.log('${TEST_END_MARKER}'); @@ -390,7 +390,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById setAttribute calls through to the host device', async () => { await runtime.load(` onEvent(async (event) => { - await devices.getById('device-b').setAttribute('label', 'updated'); + await devices.getById('${deviceB.getDeviceId}').setAttribute('label', 'updated'); console.log('${TEST_END_MARKER}'); }); `); @@ -436,7 +436,7 @@ describe('ScriptRuntime (isolated-vm)', () => { runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceB); await allDone; - expect(received).toEqual(['device-a', 'device-b']); + expect(received).toEqual([deviceA.getDeviceId, deviceB.getDeviceId]); }); // ----------------------------------------------------------------------- diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index c51f7aae..3e4c0e22 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -18,7 +18,7 @@ describe('deviceManager', () => { const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, new Map(), mockedLogger); - const device = new TestDevice('foo', 'Foo', new Date(), false, new EventEmitter()); + const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); // New device connected expect(deviceManager.getConnectedDevices().length).toBe(0); @@ -39,7 +39,7 @@ describe('deviceManager', () => { it('it removes device from managed devices and emits event on disconnect', async () => { const connectedDevices = new Map(); - const device = new TestDevice('foo', 'Foo', new Date(), false, new EventEmitter()); + const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); const mockedDeviceManagerEventEmitter = mock(); @@ -64,7 +64,7 @@ describe('deviceManager', () => { it('it emits an event on device update', async () => { const connectedDevices = new Map(); - const device = new TestDevice('foo', 'Foo', new Date(), false, new EventEmitter()); + const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); const mockedDeviceManagerEventEmitter = mock(); @@ -114,7 +114,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-1'); - const deviceInfo: DeviceInfo = { id: deviceId }; + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -166,7 +166,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-2'); - const deviceInfo: DeviceInfo = { id: deviceId }; + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -209,7 +209,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-3'); - const deviceInfo: DeviceInfo = { id: deviceId }; + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -240,7 +240,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-4'); - const deviceInfo: DeviceInfo = { id: deviceId }; + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -266,7 +266,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-5'); - const deviceInfo: DeviceInfo = { id: deviceId }; + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; beforeEach(() => { mockedLogger = mock(); diff --git a/tests/unit/device/protocol/virtual/display/displayVirtualDevice.spec.ts b/tests/unit/device/protocol/virtual/display/displayVirtualDevice.spec.ts index 5f5e9e9f..36097695 100644 --- a/tests/unit/device/protocol/virtual/display/displayVirtualDevice.spec.ts +++ b/tests/unit/device/protocol/virtual/display/displayVirtualDevice.spec.ts @@ -5,6 +5,7 @@ import VirtualDevice from "../../../../../../src/device/protocol/virtual/virtual import DisplayVirtualDeviceLogic from "../../../../../../src/device/protocol/virtual/display/displayVirtualDeviceLogic.js"; import Logger from "../../../../../../src/logging/Logger.js"; +import { DeviceId } from '../../../../../../src/device/deviceId.js'; describe('DisplayVirtualDevice', () => { @@ -12,7 +13,7 @@ describe('DisplayVirtualDevice', () => { const virtualDeviceLogic = new DisplayVirtualDeviceLogic({}); return new VirtualDevice( '1.0.0', - 'device-id', + DeviceId.create('device-id'), 'device name', 'device model', 'buttplugIo', diff --git a/tests/unit/device/testDevice.ts b/tests/unit/device/testDevice.ts index 0a5e8ddf..7f707383 100644 --- a/tests/unit/device/testDevice.ts +++ b/tests/unit/device/testDevice.ts @@ -1,6 +1,5 @@ import { EventEmitter } from "events"; -import Device, {DeviceAttributes} from "../../../src/device/device.js"; -import ExtractAttributeValue from "../../../src/device/device.js"; +import Device, {AttributeKeyOf, AttributeValueOf, DeviceAttributes} from "../../../src/device/device.js"; import { DeviceId } from '../../../src/device/deviceId.js'; export default class TestDevice extends Device @@ -15,10 +14,9 @@ export default class TestDevice extends Device super(deviceId, deviceName, 'dummy', connectedSince, controllable, {}, {}, eventEmitter); } - public setAttribute< - K extends keyof DeviceAttributes, - V extends ExtractAttributeValue - >(attributeName: K, value: V): Promise { + public async setAttribute< + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValueOf): Promise> { throw new Error("Method not implemented."); } } diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts index 0992fa36..c3e27676 100644 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -134,7 +134,7 @@ describe('SerialPortObserver', () => { const observer = createObserver(); await observer.start(); - await vi.advanceTimersByTimeAsync(3000); + await observer.discoverSerialDevices(); // manually trigger a discovery run expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledOnce(); expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledWith( @@ -150,9 +150,10 @@ describe('SerialPortObserver', () => { .mockResolvedValueOnce([port]); // third run: back const observer = createObserver(); + await observer.start(); - await vi.advanceTimersByTimeAsync(3000); - await vi.advanceTimersByTimeAsync(3000); + await observer.discoverSerialDevices(); // manually trigger a discovery run + await observer.discoverSerialDevices(); // manually trigger a discovery run expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); }); From 84e0feb896475f650f7bebc51645a4331d19b6a2 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 27 Jun 2026 17:31:25 +0200 Subject: [PATCH 31/42] Remove doc/ folder --- docs/adr/0001-ble-transport-architecture.md | 48 --------------------- 1 file changed, 48 deletions(-) delete mode 100644 docs/adr/0001-ble-transport-architecture.md diff --git a/docs/adr/0001-ble-transport-architecture.md b/docs/adr/0001-ble-transport-architecture.md deleted file mode 100644 index b4514407..00000000 --- a/docs/adr/0001-ble-transport-architecture.md +++ /dev/null @@ -1,48 +0,0 @@ -# ADR 0001 — BLE Transport Architecture - -**Status**: Accepted -**Date**: 2026-05-09 - -## Context - -Adding BLE support introduced several design decisions that affect how BLE and serial transports coexist in the device layer. - -## Decisions - -### 1. Device owns its Transport lifecycle - -**Decision**: A `BleDevice` holds a reference to its `BleUartDeviceTransport` and closes it in `doClose()`, mirroring how `PeripheralDevice` closes its serial transport. The provider does not manage transport cleanup via event listeners. - -**Alternatives considered**: Have the `BleDeviceProvider` listen for `DeviceEvent.deviceDisconnected` and close the transport there. This was the initial implementation but creates an implicit contract: if the device is ever closed without the provider's listener being registered, the transport leaks. - -**Why**: Ownership of the transport belongs to the device, not to the provider. The type system can enforce this; event listeners cannot. - ---- - -### 2. `DeviceInfo` uses a discriminant union, not structural duck typing - -**Decision**: `DeviceInfo` is a tagged union `{ type: 'ble'; ... } | { type: 'serial'; ... }`. Providers filter on `deviceInfo.type`, not on presence of fields like `'peripheral' in deviceInfo`. - -**Alternatives considered**: Keep the structural extension pattern (`BleDeviceInfo extends DeviceInfo`). This works but TypeScript cannot enforce that a provider has narrowed the type before accessing transport-specific fields, so a provider that forgets to filter silently receives wrong-transport detections. - -**Why**: The discriminant makes wrong-transport handling a compile error, not a runtime bug. Every provider is forced to handle the union explicitly. - ---- - -### 3. `BleDeviceProvider` abstract base class - -**Decision**: Introduce `BleDeviceProvider` as an abstract base class analogous to `SerialDeviceProvider`. It handles: the `deviceDetected` listener, the `type === 'ble'` guard, the acquire → transport → `addDevice` → claim flow, and peripheral disconnect-on-failure cleanup. Concrete BLE protocol providers implement a single abstract method (e.g. `connectBleDevice`). - -**Alternatives considered**: Leave `AiroticDeviceProvider` standalone. Acceptable for a single BLE protocol, but since more BLE protocols are planned, the acquire/transport/claim plumbing would be duplicated in each one. - -**Why**: The pattern is already proven by `SerialDeviceProvider`. Extracting it now costs little and prevents duplication as BLE protocols accumulate. - ---- - -### 4. BLE reconnect hides from the Operator; state is reconciled on reconnect, not buffered in order - -**Decision**: A BLE Device must appear continuously connected during a peripheral-level reconnect. No `deviceDisconnected` / `deviceConnected` lifecycle events are emitted during a reconnect attempt — only when reconnect finally fails. After a successful reconnect, `BleDevice` calls an abstract `syncState()` method that the concrete device implements. `syncState()` re-sends the current value of every `rw` Device Attribute to the hardware. `wo` attributes (one-shot triggers such as `reboot`) are never replayed. - -**Alternatives considered**: Buffer raw bytes in `BleUartDeviceTransport` and replay them in order after reconnect. Rejected because replaying a sequence of commands into a freshly-reconnected device that may not be in the assumed prior state is fragile, and `MessageResponseHandler` would receive stale or mismatched responses. - -**Why**: For attribute-style control, intermediate states are not meaningful — only the final desired state matters. Reconciling by replaying the current attribute values is safe, idempotent, and correct even if multiple writes happened during the disconnect window. From 95805fd60a9b36ae15dc8a80b91d2e4fe7653063 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 27 Jun 2026 17:55:11 +0200 Subject: [PATCH 32/42] Clean up controllers --- src/controller/automation/deleteScriptController.ts | 3 ++- src/controller/automation/getScriptController.ts | 3 ++- tests/unit/device/testDeviceProvider.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/controller/automation/deleteScriptController.ts b/src/controller/automation/deleteScriptController.ts index 65e47fea..1808b597 100644 --- a/src/controller/automation/deleteScriptController.ts +++ b/src/controller/automation/deleteScriptController.ts @@ -4,6 +4,7 @@ import AutomationScriptRepositoryInterface from '../../repository/automationScri import { isValidAutomationScriptFileName } from '../../automation/utils.js'; type RequestParams = { fileName: string }; +type DeleteScriptRequest = Request; export default class DeleteScriptController implements ControllerInterface { @@ -15,7 +16,7 @@ export default class DeleteScriptController implements ControllerInterface this.automationScriptRepository = automationScriptRepository; } - public execute(req: Request, res: Response): void + public execute(req: DeleteScriptRequest, res: Response): void { const { fileName } = req.params; diff --git a/src/controller/automation/getScriptController.ts b/src/controller/automation/getScriptController.ts index f625c276..9d728b62 100644 --- a/src/controller/automation/getScriptController.ts +++ b/src/controller/automation/getScriptController.ts @@ -4,6 +4,7 @@ import AutomationScriptRepositoryInterface from '../../repository/automationScri import { isValidAutomationScriptFileName } from '../../automation/utils.js'; type RequestParams = { fileName: string }; +type GetScriptRequest = Request; export default class GetScriptController implements ControllerInterface { @@ -15,7 +16,7 @@ export default class GetScriptController implements ControllerInterface this.automationScriptRepository = automationScriptRepository; } - public execute(req: Request, res: Response): void + public execute(req: GetScriptRequest, res: Response): void { const { fileName } = req.params; diff --git a/tests/unit/device/testDeviceProvider.ts b/tests/unit/device/testDeviceProvider.ts index f7d43535..712baae1 100644 --- a/tests/unit/device/testDeviceProvider.ts +++ b/tests/unit/device/testDeviceProvider.ts @@ -3,7 +3,7 @@ import DeviceProvider from "../../../src/device/provider/deviceProvider.js"; import Logger from "../../../src/logging/Logger.js"; import DeviceManager from "../../../src/device/deviceManager.js"; -export default class TestDeviceProvider extends DeviceProvider +export default class TestDeviceProvider extends DeviceProvider { public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { From a03beaa4e345c39c766187859d24a9a2c04db04a Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 28 Jun 2026 11:40:12 +0200 Subject: [PATCH 33/42] Prepare for device events --- src/app.ts | 16 +++++----- src/device/bleDevice.ts | 19 ++++-------- src/device/peripheralDevice.ts | 6 ++-- src/device/protocol/airotic/airoticDevice.ts | 30 ++++++++++++++++++- src/device/protocol/estim2b/estim2bDevice.ts | 4 +-- .../protocol/estim2b/estim2bDeviceFactory.ts | 4 +-- src/device/protocol/messageResponseHandler.ts | 10 +++---- .../slvCtrlPlus/genericSlvCtrlPlusDevice.ts | 4 +-- .../protocol/slvCtrlPlus/slvCtrlPlusDevice.ts | 4 +-- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 8 ++--- .../slvCtrlPlusSerialDeviceProvider.ts | 4 +-- .../virtual/audio/piperVirtualDeviceLogic.ts | 12 +++++++- src/device/protocol/virtual/virtualDevice.ts | 4 +++ .../protocol/virtual/virtualDeviceLogic.ts | 4 +++ .../protocol/virtual/virtualDeviceProvider.ts | 4 +++ src/device/protocol/zc95/zc95Device.ts | 4 +-- src/device/protocol/zc95/zc95DeviceFactory.ts | 4 +-- src/device/provider/bleDeviceProvider.ts | 10 +++++++ src/device/transport/bleDeviceTransport.ts | 10 +++++-- src/device/transport/bleObserver.ts | 12 ++++++++ .../transport/deviceBidirectionalTransport.ts | 12 ++++++++ ...ransport.ts => deviceReadableTransport.ts} | 15 +--------- .../transport/deviceWritableTransport.ts | 29 ++++++++++++++++++ src/device/transport/serialDeviceTransport.ts | 4 +-- src/device/transport/serialPortObserver.ts | 2 +- src/index.ts | 5 +++- tests/unit/device/bleDevice.spec.ts | 7 +---- .../protocol/estim2b/estim2bDevice.spec.ts | 30 +++++++++---------- .../genericSlvCtrlPlusDevice.spec.ts | 20 ++++++------- .../device/protocol/zc95/zc95Device.spec.ts | 6 ++-- 30 files changed, 199 insertions(+), 104 deletions(-) create mode 100644 src/device/transport/deviceBidirectionalTransport.ts rename src/device/transport/{deviceTransport.ts => deviceReadableTransport.ts} (56%) create mode 100644 src/device/transport/deviceWritableTransport.ts diff --git a/src/app.ts b/src/app.ts index bf58e7ac..a9727f9b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -129,6 +129,7 @@ const configureWebsocket = (io: WebsocketServer, container: Container): void => { const serialPortObserver = container.get('device.observer.serial'); + const bleObserver = container.get('device.observer.ble'); const logger = container.get('logger.default'); const settings = container.get('settings'); const deviceProviderManager = container.get('device.provider.loader'); @@ -140,6 +141,7 @@ const loadDeviceProviders = (container: Container): void => { .catch(e => logError(logger, `Loading device providers failed`, e)); serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e)); + bleObserver.init().catch(e => logError(logger, `Initializing BLE observer failed`, e)); }; const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({ @@ -200,7 +202,6 @@ export const createApp = (container: Container, options: AppOptions) configureWebsocket(websocketServer, container); loadDeviceProviders(container); - let serveResult: ServeResult | undefined; let canBeShutDown = false; @@ -250,23 +251,22 @@ export const createApp = (container: Container, options: AppOptions) canBeShutDown = false; const logger = container.get('logger.default'); - const closeServer = (server: http.Server): Promise => server.listening - ? new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve())) - : Promise.resolve(); - logger.info('Shutting down...'); await container.get('automation.scriptRuntime').stop(); - container.get('device.observer.serial').stop(); + await container.get('device.observer.serial').stop(); + await container.get('device.observer.ble').stop(); await container.get('device.provider.loader').stopProviders(); container.get('health.metricsCollector').stop(); await websocketServer.close(); if (serveResult) { - await closeServer(serveResult.httpServer); + serveResult.httpServer.closeAllConnections(); + serveResult.httpServer.close(); if (serveResult.httpsServer) { - await closeServer(serveResult.httpsServer); + serveResult.httpsServer.closeAllConnections(); + serveResult.httpsServer.close(); } } }, diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 8587dee6..02624700 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -8,7 +8,6 @@ import { DeviceId } from './deviceId.js'; import { logError } from '../util/error.js'; import Logger from '../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../util/async.js'; -import BleUartDeviceTransport from './transport/bleDeviceTransport.js'; export type InferBleDeviceAttributes> = D extends BleDevice ? TAttrs : DeviceAttributes; @@ -23,10 +22,9 @@ export default abstract class BleDevice< { private readonly peripheral: Peripheral; - private readonly transport: BleUartDeviceTransport; - private readonly rssiInterval: NodeJS.Timeout; private readonly reconnectHandler: () => void; + private closing: boolean = false; @Expose() private rssi: number; @@ -38,7 +36,6 @@ export default abstract class BleDevice< deviceName: string, provider: string, peripheral: Peripheral, - transport: BleUartDeviceTransport, connectedSince: Date, controllable: boolean, attributes: TAttributes, @@ -51,15 +48,8 @@ export default abstract class BleDevice< this.logger = logger.child({ name: this.constructor.name }); this.peripheral = peripheral; - this.transport = transport; this.rssi = peripheral.rssi; - transport.onConnected(() => { - this.syncState().catch( - (e: unknown) => logError(this.logger, `Error syncing state after reconnect for device ${this.deviceId}`, e) - ); - }); - this.rssiInterval = setInterval(asyncHandler( async () => await this.requestRssiUpdate(), (e: unknown) => logError(this.logger,`Error during RSSI update for device ${this.deviceId}`, e) @@ -90,7 +80,7 @@ export default abstract class BleDevice< protected abstract syncState(): Promise; private async requestRssiUpdate(): Promise { - if (this.peripheral.state === 'disconnected') { + if (this.closing || this.peripheral.state === 'disconnected') { return; } @@ -107,12 +97,15 @@ export default abstract class BleDevice< } protected override async doClose(): Promise { - await this.transport.close(); + this.closing = true; if (this.peripheral.state === 'connected') { try { await promiseWithTimeout(this.peripheral.disconnectAsync(), 750, `Timed out (>750ms) while disconnecting from device ${this.deviceId}`); } catch (error) { + if (error instanceof Error && error.message.includes('BLEManager has already been cleaned up')) { + return; + } logError(this.logger, `Error disconnecting device ${this.deviceId}`, error); } } else if (this.peripheral.state === 'connecting') { diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 40d9d143..646bc5c6 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -1,5 +1,5 @@ import Device, { DeviceAttributes } from './device.js'; -import DeviceTransport from './transport/deviceTransport.js'; +import BidirectionalDeviceTransport from './transport/deviceBidirectionalTransport.js'; import DeviceProtocol, { MessageWithResponse } from './protocol/deviceProtocol.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import EventEmitter from 'events'; @@ -17,7 +17,7 @@ export default abstract class PeripheralDevice< TConfig extends AnyDeviceConfig = NoDeviceConfig > extends Device { - protected readonly transport: DeviceTransport; + protected readonly transport: BidirectionalDeviceTransport; protected readonly protocol: TProtocol; @@ -28,7 +28,7 @@ export default abstract class PeripheralDevice< connectedSince: Date, controllable: boolean, protocol: TProtocol, - transport: DeviceTransport, + transport: BidirectionalDeviceTransport, attributes: TAttributes, config: TConfig, eventEmitter: EventEmitter diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 1294552f..e486d860 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -12,6 +12,7 @@ import AiroticProtocol from './airtonicProtocol.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import { sleep } from '../../../util/async.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; +import { logError } from '../../../util/error.js'; export type AiroticDeviceAttributes = { restColor: StrDeviceAttribute, @@ -25,6 +26,8 @@ export default class AiroticDevice extends BleDevice; + private readonly transport: BleUartDeviceTransport; + public constructor( deviceId: DeviceId, deviceName: string, @@ -39,9 +42,29 @@ export default class AiroticDevice extends BleDevice { + this.syncState().catch( + (e: unknown) => logError(this.logger, `Error syncing state after reconnect for device ${this.deviceId}`, e) + ); + }); + + this.transport.onReceive(data => this.onReceiveTransportData(data)); + } + + private onReceiveTransportData(data: Buffer): void { + const dataStr = data.toString('utf-8'); + if ('*B' !== dataStr && '*R' !== dataStr) { + return; + } + + // *B = Breath In Color, *R = Rest Color + + this.logger.debug(`Received data from device ${this.deviceId}: ${dataStr}`); } protected override async syncState(): Promise { @@ -101,6 +124,11 @@ export default class AiroticDevice extends BleDevice { + await super.doClose(); + await this.transport.close(); + } + private parseColor(value: string): { r: number, g: number, b: number } { const channels = value.split(','); diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index 79efa15d..db58acec 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -7,7 +7,7 @@ import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import ListDeviceAttribute from '../../attribute/listDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; -import DeviceTransport from '../../transport/deviceTransport.js'; +import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import PeripheralDevice from '../../peripheralDevice.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import EventEmitter from 'events'; @@ -44,7 +44,7 @@ export default class EStim2bDevice extends PeripheralDevice { diff --git a/src/device/protocol/messageResponseHandler.ts b/src/device/protocol/messageResponseHandler.ts index 4e08e689..598acbc1 100644 --- a/src/device/protocol/messageResponseHandler.ts +++ b/src/device/protocol/messageResponseHandler.ts @@ -1,5 +1,5 @@ import DeviceProtocol, { InferMR, InferResponse, MessageWithOptionalResponse, MessageWithResponse } from './deviceProtocol.js'; -import DeviceTransport from '../transport/deviceTransport.js'; +import DeviceBidirectionalTransport from '../transport/deviceBidirectionalTransport.js'; import { clearTimeout } from 'node:timers'; import Logger from '../../logging/Logger.js'; import { promiseWithTimeout } from '../../util/async.js'; @@ -16,14 +16,14 @@ type PendingEntry = { export default class MessageResponseHandler

>> { private readonly protocol: P; - private readonly transport: DeviceTransport; + private readonly transport: DeviceBidirectionalTransport; private readonly logger: Logger; private readonly pendingEntries: Set>> = new Set(); private readonly timeoutMs: number; public static create

>>( protocol: P, - transport: DeviceTransport, + transport: DeviceBidirectionalTransport, logger: Logger, timeoutMs: number = 200, ): MessageResponseHandler

{ @@ -32,7 +32,7 @@ export default class MessageResponseHandler

{ + public async create(deviceId: DeviceId, transport: DeviceBidirectionalTransport, provider: string): Promise { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; const knownDevice = this.createKnownDevice(deviceId, deviceInfo.deviceType, provider); @@ -65,7 +65,7 @@ export default class SlvCtrlPlusDeviceFactory return device; } - private async getDeviceInfo(transport: DeviceTransport): Promise + private async getDeviceInfo(transport: DeviceBidirectionalTransport): Promise { const infoResponse = await transport.sendAndAwaitReceive( Buffer.from(`introduce`), @@ -97,7 +97,7 @@ export default class SlvCtrlPlusDeviceFactory return { fwVersion, protocolVersion, deviceType: deviceInfo.type, protocol }; } - private async getAttributes(transport: DeviceTransport, protocol: SlvCtrlProtocol): Promise + private async getAttributes(transport: DeviceBidirectionalTransport, protocol: SlvCtrlProtocol): Promise { const attrResponse = await transport.sendAndAwaitReceive( protocol.encode({ command: 'attributes', args: [] }), diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts index 8edc1643..af1f1445 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts @@ -10,7 +10,7 @@ import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../.. import SerialPortFactory from '../../../factory/serialPortFactory.js'; import BaseError from 'modern-errors'; import SlvCtrlProtocol from './slvCtrlProtocol.js'; -import DeviceTransport from '../../../device/transport/deviceTransport.js'; +import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import DeviceManager from '../../deviceManager.js'; import GenericSlvCtrlPlusDevice from './genericSlvCtrlPlusDevice.js'; import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; @@ -59,7 +59,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide return device; } - private async performHandshakeWithRetries(transport: DeviceTransport, maxAttempts: number): Promise { + private async performHandshakeWithRetries(transport: DeviceBidirectionalTransport, maxAttempts: number): Promise { let lastError; for (let i = 1; i <= maxAttempts; i++) { diff --git a/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts b/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts index 14d0cad0..fb74ba84 100644 --- a/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts @@ -125,9 +125,9 @@ export default class PiperVirtualDeviceLogic extends VirtualDeviceLogic< this.config.binary ?? 'piper', ['--model', this.config.model, '--output-raw'], { - env: { ...process.env, PIPER_NO_PLAYER: '1' }, stdio: ['pipe', 'pipe', 'pipe'], + detached: true, } ); @@ -218,6 +218,16 @@ export default class PiperVirtualDeviceLogic extends VirtualDeviceLogic< this.piperProcess.stdout.pipe(this.speaker); } + public override async destroy(): Promise { + this.stopPlayback(); + + if (this.piperProcess !== undefined) { + this.piperProcess.stdin.destroy(); + this.piperProcess.kill(); + this.piperProcess = undefined; + } + } + private stopPlayback(): boolean { if (undefined === this.piperProcess || undefined === this.speaker) { return false; diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index 40961ea5..29b54699 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -44,6 +44,10 @@ export default class VirtualDevice< this.logger = logger; } + protected override async doClose(): Promise { + await this.deviceLogic.destroy(); + } + protected override async doRefresh(): Promise { try { await this.deviceLogic.refreshData(this); diff --git a/src/device/protocol/virtual/virtualDeviceLogic.ts b/src/device/protocol/virtual/virtualDeviceLogic.ts index 445a7741..b56b084c 100644 --- a/src/device/protocol/virtual/virtualDeviceLogic.ts +++ b/src/device/protocol/virtual/virtualDeviceLogic.ts @@ -27,4 +27,8 @@ export default abstract class VirtualDeviceLogic< public abstract configureAttributes(): TAttributes; public abstract get refreshInterval(): number; + + public async destroy(): Promise { + // no-op + } } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 2d871fb6..f90e09ee 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -51,6 +51,10 @@ export default class VirtualDeviceProvider extends DeviceProvider clearInterval(this.discoveryInterval); this.discoveryInterval = undefined; } + + for (const device of this.connectedDevices.values()) { + await this.removeDevice(device); + } } private async discoverVirtualDevices(): Promise { diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index 01d47f34..06ef7333 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -19,7 +19,7 @@ import { AllOrNone } from '../../../types.js'; import { NoDeviceConfig } from '../../deviceConfig.js'; import PeripheralDevice from '../../peripheralDevice.js'; import Zc95Protocol, { MsgResponse } from './zc95Protocol.js'; -import DeviceTransport from '../../transport/deviceTransport.js'; +import BidirectionalDeviceTransport from '../../transport/deviceBidirectionalTransport.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import Logger from '../../../logging/Logger.js'; import EventEmitter from 'events'; @@ -73,7 +73,7 @@ export default class Zc95Device extends PeripheralDevice, provider: string diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index c458e8dc..1f21927e 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -16,6 +16,8 @@ export default abstract class BleDeviceProvider< TConfig extends AnyDeviceConfig = InferBleDeviceConfig > extends DeviceProvider { + private connectedDevices: Set = new Set(); + protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); @@ -50,6 +52,7 @@ export default abstract class BleDeviceProvider< return; } + this.connectedDevices.add(device); this.deviceManager.addDevice(device); this.deviceManager.claimDetectedDevice(deviceInfo.id); } catch (e: unknown) { @@ -59,6 +62,13 @@ export default abstract class BleDeviceProvider< } } + public override async stop(): Promise { + for (const device of this.connectedDevices) { + await device.close(); + } + this.connectedDevices.clear(); + } + private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { return deviceInfo.type === 'ble'; } diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index 44666d02..378c66c6 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -1,8 +1,8 @@ import { Characteristic, Peripheral } from '@stoprocent/noble'; -import DeviceTransport from './deviceTransport.js'; +import DeviceBidirectionalTransport from './deviceBidirectionalTransport.js'; import { asyncHandler } from '../../util/async.js'; -export default class BleUartDeviceTransport implements DeviceTransport +export default class BleUartDeviceTransport implements DeviceBidirectionalTransport { private readonly peripheral: Peripheral; private rx?: Characteristic; @@ -130,7 +130,11 @@ export default class BleUartDeviceTransport implements DeviceTransport for (const subscriber of this.onReceiveSubscribers) { this.tx.removeListener('data', subscriber); } - await this.tx.unsubscribeAsync(); + try { + await this.tx.unsubscribeAsync(); + } catch { + // BLE manager may already be torn down on shutdown — ignore + } } this.isConnected = false; diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index dd665478..8374bb18 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -47,6 +47,18 @@ export default class BleObserver await this.observe(); } + public async stop(): Promise + { + noble.removeAllListeners(); + + if (this.isScanning) { + await noble.stopScanningAsync(); + this.isScanning = false; + } + + noble.stop(); + } + private onDiscover(peripheral: Peripheral): void { if (peripheral.rssi < BleObserver.MIN_RSSI) { diff --git a/src/device/transport/deviceBidirectionalTransport.ts b/src/device/transport/deviceBidirectionalTransport.ts new file mode 100644 index 00000000..585473d6 --- /dev/null +++ b/src/device/transport/deviceBidirectionalTransport.ts @@ -0,0 +1,12 @@ +import DeviceReadableTransport from './deviceReadableTransport.js'; +import DeviceWritableTransport from './deviceWritableTransport.js'; + +export default interface DeviceTransport extends DeviceReadableTransport, DeviceWritableTransport +{ + /** + * Writes data and waits for a response associated to it and returns it as a result + * @param data The command/input for the device + * @param timeout The timeout the transport should wait for the operation to complete and get a response + */ + sendAndAwaitReceive(data: Buffer, timeout?: number): Promise; +} diff --git a/src/device/transport/deviceTransport.ts b/src/device/transport/deviceReadableTransport.ts similarity index 56% rename from src/device/transport/deviceTransport.ts rename to src/device/transport/deviceReadableTransport.ts index be66e6ac..2b8e21f5 100644 --- a/src/device/transport/deviceTransport.ts +++ b/src/device/transport/deviceReadableTransport.ts @@ -1,18 +1,5 @@ -export default interface DeviceTransport +export default interface DeviceReadableTransport { - /** - * Writes data and waits for a response associated to it and returns it as a result - * @param data The command/input for the device - * @param timeout The timeout the transport should wait for the operation to complete and get a response - */ - sendAndAwaitReceive(data: Buffer, timeout?: number): Promise; - - /** - * Writes data - * @param data The command/input for the device - */ - send(data: Buffer): Promise; - /** * When data is received * @param dataProcessor diff --git a/src/device/transport/deviceWritableTransport.ts b/src/device/transport/deviceWritableTransport.ts new file mode 100644 index 00000000..55563af9 --- /dev/null +++ b/src/device/transport/deviceWritableTransport.ts @@ -0,0 +1,29 @@ +export default interface DeviceWritableTransport +{ + /** + * Writes data + * @param data The command/input for the device + */ + send(data: Buffer): Promise; + + /** + * Called when the transport is closed (for example when a serial device is unplugged) + * @param callback + */ + onClose(callback: () => Promise): void; + + /** + * Returns whether the transport is currently open/active or not + */ + isOpen(): boolean; + + /** + * Closes the transport + */ + close(): Promise; + + /** + * Returns a deterministic and unique device identifier (for example hardware serial number) + */ + getDeviceIdentifier(): string; +} diff --git a/src/device/transport/serialDeviceTransport.ts b/src/device/transport/serialDeviceTransport.ts index 80a43a91..f01df415 100644 --- a/src/device/transport/serialDeviceTransport.ts +++ b/src/device/transport/serialDeviceTransport.ts @@ -1,7 +1,7 @@ -import DeviceTransport from './deviceTransport.js'; +import DeviceBidirectionalTransport from './deviceBidirectionalTransport.js'; import SynchronousSerialPort from '../../serial/synchronousSerialPort.js'; -export default class SerialDeviceTransport implements DeviceTransport +export default class SerialDeviceTransport implements DeviceBidirectionalTransport { private readonly serialPort: SynchronousSerialPort; private readonly frameStartBytes?: Buffer; diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 3e8aeaa3..a3d78f69 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -110,7 +110,7 @@ export default class SerialPortObserver } } - public stop(): void { + public async stop(): Promise { if (this.rescanTimer !== undefined) { clearTimeout(this.rescanTimer); this.rescanTimer = undefined; diff --git a/src/index.ts b/src/index.ts index 8b9defff..5f6ee6f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,10 @@ app.serve(env.PORT, sslConfig); const shutdown = (): void => { app.shutdown() .catch((err: unknown) => logError(logger, 'Error during shutdown', err)) - .finally(() => process.exit(0)); + .finally(() => { + logger.info('Shutdown complete, exiting process'); + process.exit(0); + }); }; process.on('SIGTERM', shutdown); diff --git a/tests/unit/device/bleDevice.spec.ts b/tests/unit/device/bleDevice.spec.ts index 1916bb77..6123db6c 100644 --- a/tests/unit/device/bleDevice.spec.ts +++ b/tests/unit/device/bleDevice.spec.ts @@ -19,7 +19,6 @@ class TestBleDevice extends BleDevice { deviceName: string, provider: string, peripheral: Peripheral, - transport: BleUartDeviceTransport, connectedSince: Date, controllable: boolean, attributes: DeviceAttributes, @@ -27,7 +26,7 @@ class TestBleDevice extends BleDevice { eventEmitter: EventEmitter, logger: Logger, ) { - super(deviceId, deviceName, provider, peripheral, transport, connectedSince, controllable, attributes, config, eventEmitter, logger); + super(deviceId, deviceName, provider, peripheral, connectedSince, controllable, attributes, config, eventEmitter, logger); } public async setAttribute>( @@ -44,7 +43,6 @@ class TestBleDevice extends BleDevice { describe('BleDevice', () => { let mockPeripheral: ReturnType>; - let mockTransport: ReturnType>; let mockLogger: ReturnType>; let peripheralState: PeripheralState; @@ -54,7 +52,6 @@ describe('BleDevice', () => { 'Test Device', 'test-provider', mockPeripheral, - mockTransport, new Date(), true, {}, @@ -68,7 +65,6 @@ describe('BleDevice', () => { vi.useFakeTimers(); mockPeripheral = mock(); - mockTransport = mock(); mockLogger = mock(); mockLogger.child.mockReturnValue(mockLogger); @@ -78,7 +74,6 @@ describe('BleDevice', () => { mockPeripheral.disconnectAsync.mockResolvedValue(undefined); mockPeripheral.updateRssiAsync.mockResolvedValue(-60); - mockTransport.close.mockResolvedValue(undefined); }); afterEach(() => { diff --git a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts index e6c30d05..fcbf3268 100644 --- a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts +++ b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import EStim2bDevice, { EStim2bDeviceAttributes } from '../../../../../src/device/protocol/estim2b/estim2bDevice.js'; import EStim2bProtocol, { EStim2bMode, EStim2bStatus } from '../../../../../src/device/protocol/estim2b/estim2bProtocol.js'; -import DeviceTransport from '../../../../../src/device/transport/deviceTransport.js'; +import DeviceWritableTransport from '../../../../../src/device/transport/deviceWritableTransport.js'; import IntRangeDeviceAttribute from '../../../../../src/device/attribute/intRangeDeviceAttribute.js'; import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceAttribute.js'; import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAttribute.js'; @@ -61,7 +61,7 @@ describe('EStim2bDevice', () => { function createDevice( status: EStim2bStatus, protocol: EStim2bProtocol, - transport: DeviceTransport, + transport: DeviceWritableTransport, ): EStim2bDevice { return new EStim2bDevice( DeviceId.create('device-id'), @@ -95,7 +95,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); const responseStatus = createStatus({ currentMode: EStim2bMode.bounce }); const encodedCommand = Buffer.from('M1'); @@ -123,7 +123,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus(); const responseStatus = createStatus({ channelALevel: 50 }); const encodedCommand = Buffer.from('A50'); @@ -149,7 +149,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus(); const responseStatus = createStatus({ channelBLevel: 30 }); const encodedCommand = Buffer.from('B30'); @@ -175,7 +175,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); const responseStatus = createStatus({ currentMode: EStim2bMode.pulse, pulseFrequency: 75 }); const encodedCommand = Buffer.from('C75'); @@ -201,7 +201,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); const responseStatus = createStatus({ currentMode: EStim2bMode.pulse, pulsePwm: 25 }); const encodedCommand = Buffer.from('D25'); @@ -227,7 +227,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ powerMode: 'L' }); const responseStatus = createStatus({ powerMode: 'H' }); const encodedCommand = Buffer.from('H'); @@ -253,7 +253,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ powerMode: 'H' }); const responseStatus = createStatus({ powerMode: 'L' }); const encodedCommand = Buffer.from('L'); @@ -279,7 +279,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const device = createDevice(createStatus(), mockProtocol, mockTransport); // Act @@ -296,7 +296,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const device = createDevice(createStatus(), mockProtocol, mockTransport); // Act @@ -313,7 +313,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); // Continuous mode only adds pulseFrequency — pulsePwm is absent const device = createDevice(createStatus({ currentMode: EStim2bMode.continuous }), mockProtocol, mockTransport); @@ -329,7 +329,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const errorMessage = 'Connection lost'; mockProtocol.createSetPowerCommand.mockReturnValue('A50'); @@ -350,7 +350,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const responseBuffer = Buffer.from('bad response'); mockProtocol.createSetPowerCommand.mockReturnValue('A50'); @@ -371,7 +371,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus(); const refreshedStatus = createStatus({ channelALevel: 75, diff --git a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts index f4526517..cbc445ac 100644 --- a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts +++ b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts @@ -6,7 +6,7 @@ import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceA import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; import SlvCtrlProtocol from '../../../../../src/device/protocol/slvCtrlPlus/slvCtrlProtocol.js'; import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAttribute.js'; -import DeviceTransport from '../../../../../src/device/transport/deviceTransport.js'; +import DeviceWritableTransport from '../../../../../src/device/transport/deviceWritableTransport.js'; import { matchStrictlyEqual } from '../../../helper/matchers.js'; import Logger from '../../../../../src/logging/Logger.js'; import EventEmitter from 'events'; @@ -17,7 +17,7 @@ describe('GenericSlvCtrlPlusDevice', () => { function createDevice( attrs: SlvCtrlPlusDeviceAttributes, protocol: SlvCtrlProtocol, - transport: DeviceTransport, + transport: DeviceWritableTransport, ): GenericSlvCtrlPlusDevice { const fwVersion = 10000; const deviceUuid = DeviceId.create('foo-bar-baz'); @@ -55,7 +55,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const attrName = 'bool'; const device = createDevice({}, mockProtocol, mockTransport); @@ -74,7 +74,7 @@ describe('GenericSlvCtrlPlusDevice', () => { ])('it sets value for $attribute.constructor.name successfully', async ({ attribute, valueToSet, protocolValue }) => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const command = { command: 'set', args: [attribute.name, valueToSet] }; const rawProtocolCommand = `set ${attribute.name} ${protocolValue}`; @@ -118,7 +118,7 @@ describe('GenericSlvCtrlPlusDevice', () => { const rawProtocolCommand = `set ${attrName} 1\n`; const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); mockProtocol.encode .calledWith(matchStrictlyEqual(command)) @@ -146,7 +146,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const attrName = 'bool'; const device = createDevice({ @@ -167,7 +167,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const attrName = 'bool'; const device = createDevice( @@ -189,7 +189,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const mockLogger = mock(); const device = new GenericSlvCtrlPlusDevice( @@ -242,7 +242,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const mockLogger = mock(); const device = new GenericSlvCtrlPlusDevice( @@ -293,7 +293,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const mockLogger = mock(); const device = new GenericSlvCtrlPlusDevice( diff --git a/tests/unit/device/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts index e647e682..ed0e0f06 100644 --- a/tests/unit/device/protocol/zc95/zc95Device.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -3,7 +3,7 @@ import { mock, MockProxy } from 'vitest-mock-extended'; import { EventEmitter } from 'events'; import Zc95Device, { Zc95DeviceAttributes } from '../../../../../src/device/protocol/zc95/zc95Device.js'; import Zc95Protocol, { MsgAndResponseIdentifier, MsgResponse } from '../../../../../src/device/protocol/zc95/zc95Protocol.js'; -import DeviceTransport from '../../../../../src/device/transport/deviceTransport.js'; +import DeviceWritableTransport from '../../../../../src/device/transport/deviceWritableTransport.js'; import MessageResponseHandler from '../../../../../src/device/protocol/messageResponseHandler.js'; import Zc95MessageFactory, { AckMsgResponse, @@ -22,7 +22,7 @@ import Logger from '../../../../../src/logging/Logger.js'; describe('Zc95Device', () => { let mockProtocol: MockProxy; - let mockTransport: MockProxy; + let mockTransport: MockProxy; let mockMsgHandler: MockProxy>; let mockMsgFactory: MockProxy; let mockLogger: MockProxy; @@ -104,7 +104,7 @@ describe('Zc95Device', () => { beforeEach(() => { mockProtocol = mock(); - mockTransport = mock(); + mockTransport = mock(); mockMsgHandler = mock>(); mockMsgFactory = mock(); mockLogger = mock(); From 9ac7fe7f88ef14a58b1795cb9ef8f634cdc98252 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 28 Jun 2026 12:52:30 +0200 Subject: [PATCH 34/42] wip --- src/device/bleDevice.ts | 15 ++++---- src/device/device.ts | 37 ++++++++++++++----- src/device/deviceManager.ts | 17 +++++---- src/device/peripheralDevice.ts | 13 ++++--- src/device/protocol/airotic/airoticDevice.ts | 19 ++++++++-- src/device/protocol/virtual/virtualDevice.ts | 4 +- src/device/provider/serialDeviceProvider.ts | 2 +- src/device/types.d.ts | 0 src/types.d.ts | 2 + tests/unit/device/bleDevice.spec.ts | 33 ++--------------- .../protocol/estim2b/estim2bDevice.spec.ts | 30 +++++++-------- .../genericSlvCtrlPlusDevice.spec.ts | 20 +++++----- .../device/protocol/zc95/zc95Device.spec.ts | 6 +-- 13 files changed, 103 insertions(+), 95 deletions(-) delete mode 100644 src/device/types.d.ts diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 02624700..896c011d 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -1,6 +1,6 @@ import { Peripheral } from '@stoprocent/noble'; import BaseError from 'modern-errors'; -import Device, { DeviceAttributes } from './device.js'; +import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } from './device.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import { Expose } from 'class-transformer'; import { EventEmitter } from 'events'; @@ -9,16 +9,17 @@ import { logError } from '../util/error.js'; import Logger from '../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../util/async.js'; -export type InferBleDeviceAttributes> = - D extends BleDevice ? TAttrs : DeviceAttributes; +export type InferBleDeviceAttributes> = + D extends BleDevice ? TAttrs : DeviceAttributes; -export type InferBleDeviceConfig> = - D extends BleDevice ? TCfg : AnyDeviceConfig; +export type InferBleDeviceConfig> = + D extends BleDevice ? TCfg : AnyDeviceConfig; export default abstract class BleDevice< TAttributes extends DeviceAttributes = DeviceAttributes, + TNotifications extends DeviceNotifications = NoDeviceNotifications, TConfig extends AnyDeviceConfig = NoDeviceConfig -> extends Device +> extends Device { private readonly peripheral: Peripheral; @@ -77,8 +78,6 @@ export default abstract class BleDevice< this.peripheral.on('disconnect', this.reconnectHandler); } - protected abstract syncState(): Promise; - private async requestRssiUpdate(): Promise { if (this.closing || this.peripheral.state === 'disconnected') { return; diff --git a/src/device/device.ts b/src/device/device.ts index 74454778..676057e4 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -4,16 +4,24 @@ import DeviceAttribute from './attribute/deviceAttribute.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import { EventEmitter } from 'events'; import type { DeviceId } from './deviceId.js'; +import { DropFirst } from '../types.js'; -export type InferDeviceAttributes> = - D extends Device ? TAttrs : DeviceAttributes; +export type InferDeviceAttributes> = + D extends Device ? TAttrs : DeviceAttributes; -export type InferDeviceConfig> = - D extends Device ? TCfg : AnyDeviceConfig; +export type InferDeviceNotifications> = + D extends Device ? TNotifs : AnyDeviceNotifications; + +export type InferDeviceConfig> = + D extends Device ? TCfg : AnyDeviceConfig; // An attribute value can be DeviceAttribute or undefined because we want to allow Partial<> export type DeviceAttributes = Record; +export type DeviceNotifications = Record; +export type NoDeviceNotifications = Record; +export type AnyDeviceNotifications = Record; + type InferAttributeValue = A extends DeviceAttribute ? V : never; export type AttributeKeyOf = keyof A & string; export type AttributeValueOf> = @@ -35,16 +43,25 @@ export type DeviceError = { export enum DeviceEvent { deviceDisconnected = 'deviceDisconnected', deviceRefreshed = 'deviceRefreshed', + deviceNotification = 'deviceNotification', } -export type DeviceEventMap> = { +export type DeviceNotification = + { [K in keyof TNotifications & string]: { type: K; data: TNotifications[K] } }[keyof TNotifications & string]; + +export type DeviceEventMap< + TDevice extends Device = Device, + TNotifications extends DeviceNotifications = AnyDeviceNotifications +> = { [DeviceEvent.deviceRefreshed]: [device: TDevice]; [DeviceEvent.deviceDisconnected]: [device: TDevice]; + [DeviceEvent.deviceNotification]: [device: TDevice, notification: DeviceNotification]; } @Exclude() export default abstract class Device< TAttributes extends DeviceAttributes = DeviceAttributes, + TNotifications extends DeviceNotifications = NoDeviceNotifications, TConfig extends AnyDeviceConfig = NoDeviceConfig > { @Expose() @@ -156,7 +173,7 @@ export default abstract class Device< K extends AttributeKeyOf >(attributeName: K, value: AttributeValueOf): Promise>; - public on(event: K, listener: (...args: DeviceEventMap[K]) => void): void + public on(event: K, listener: (...args: DeviceEventMap[K]) => void): void { this.eventEmitter.on(event, listener); } @@ -171,7 +188,7 @@ export default abstract class Device< await this.doClose(); } finally { this.state = DeviceState.closed; - this.emit(DeviceEvent.deviceDisconnected, this); + this.emit(DeviceEvent.deviceDisconnected); } } @@ -183,12 +200,12 @@ export default abstract class Device< protected updateLastRefresh(): void { this.lastRefresh = new Date(); - this.emit(DeviceEvent.deviceRefreshed, this); + this.emit(DeviceEvent.deviceRefreshed); } - protected emit(eventName: K, ...args: DeviceEventMap[K]): boolean + protected emit(eventName: K, ...args: DropFirst[K]>): boolean { - return this.eventEmitter.emit(eventName, ...args); + return this.eventEmitter.emit(eventName, this, ...args); } protected isAttributePresent( diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index bcb361de..4b935efe 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -1,4 +1,4 @@ -import Device, { DeviceAttributes, DeviceEvent } from './device.js'; +import Device, { DeviceAttributes, DeviceEvent, DeviceNotification, DeviceNotifications } from './device.js'; import EventEmitter from 'events'; import DeviceState from './deviceState.js'; import { setIntervalAsync } from '../util/async.js'; @@ -17,6 +17,7 @@ export enum DeviceManagerEvent { deviceDisconnected = 'deviceDisconnected', deviceRefreshed = 'deviceRefreshed', deviceDetected = 'deviceDetected', + deviceNotification = 'deviceNotification', } type AcquireResult = @@ -28,6 +29,7 @@ type DeviceManagerEventMap = { [DeviceManagerEvent.deviceDisconnected]: [device: Device]; [DeviceManagerEvent.deviceRefreshed]: [device: Device]; [DeviceManagerEvent.deviceDetected]: [deviceInfo: DeviceInfo]; + [DeviceManagerEvent.deviceNotification]: [device: Device, notification: DeviceNotification]; } export default class DeviceManager @@ -114,14 +116,15 @@ export default class DeviceManager deviceQueue[0]?.resolve({ successful: true }); } - public addDevice( - device: Device + public addDevice( + device: Device ): void { this.connectedDevices.set(device.getDeviceId, device); - device.on(DeviceEvent.deviceRefreshed, (d: Device) => this.refreshDevice(d)); - device.on(DeviceEvent.deviceDisconnected, (d: Device) => this.removeDevice(d)); + device.on(DeviceEvent.deviceRefreshed, (d) => this.refreshDevice(d)); + device.on(DeviceEvent.deviceDisconnected, (d) => this.removeDevice(d)); + device.on(DeviceEvent.deviceNotification, (d, notification) => this.eventEmitter.emit(DeviceManagerEvent.deviceNotification, d, notification)); this.initDeviceRefresher(device); @@ -209,13 +212,13 @@ export default class DeviceManager device.on(DeviceEvent.deviceDisconnected, () => deviceRefreshInterval.clear()); } - private removeDevice(device: Device): void + private removeDevice(device: Device): void { this.connectedDevices.delete(device.getDeviceId); this.eventEmitter.emit(DeviceManagerEvent.deviceDisconnected, device); } - private refreshDevice(device: Device): void + private refreshDevice(device: Device): void { this.eventEmitter.emit(DeviceManagerEvent.deviceRefreshed, device); } diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 646bc5c6..6afd3e8b 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -1,21 +1,22 @@ -import Device, { DeviceAttributes } from './device.js'; +import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } from './device.js'; import BidirectionalDeviceTransport from './transport/deviceBidirectionalTransport.js'; import DeviceProtocol, { MessageWithResponse } from './protocol/deviceProtocol.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import EventEmitter from 'events'; import { DeviceId } from './deviceId.js'; -export type InferPeripheralDeviceAttributes> = - D extends PeripheralDevice ? TAttrs : DeviceAttributes; +export type InferPeripheralDeviceAttributes> = + D extends PeripheralDevice ? TAttrs : DeviceAttributes; -export type InferPeripheralDeviceConfig> = - D extends PeripheralDevice ? TCfg : AnyDeviceConfig; +export type InferPeripheralDeviceConfig> = + D extends PeripheralDevice ? TCfg : AnyDeviceConfig; export default abstract class PeripheralDevice< TProtocol extends DeviceProtocol>, TAttributes extends DeviceAttributes = DeviceAttributes, + TNotifications extends DeviceNotifications = NoDeviceNotifications, TConfig extends AnyDeviceConfig = NoDeviceConfig -> extends Device +> extends Device { protected readonly transport: BidirectionalDeviceTransport; diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index e486d860..e9a78a04 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -1,6 +1,6 @@ import { Exclude } from 'class-transformer'; import EventEmitter from 'events'; -import { AttributeKeyOf, AttributeValueOf } from '../../device.js'; +import { AttributeKeyOf, AttributeValueOf, DeviceEvent } from '../../device.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { NoDeviceConfig } from '../../deviceConfig.js'; import { Peripheral } from '@stoprocent/noble'; @@ -21,8 +21,14 @@ export type AiroticDeviceAttributes = { reboot: BoolDeviceAttribute, }; +export type AiroticDeviceNotifications = { + colorChange: { + colorType: 'breathInColor' | 'restColor'; + }; +}; + @Exclude() -export default class AiroticDevice extends BleDevice +export default class AiroticDevice extends BleDevice { private readonly messageResponseHandler: MessageResponseHandler; @@ -65,9 +71,16 @@ export default class AiroticDevice extends BleDevice { + protected async syncState(): Promise { const { restColor, breathInColor } = this.attributes; if (undefined !== restColor.value) { diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index 29b54699..0e998c8c 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -1,6 +1,6 @@ import { Exclude, Expose } from 'class-transformer'; import BaseError from 'modern-errors'; -import Device, { AttributeKeyOf, AttributeValueOf } from '../../device.js'; +import Device, { AttributeKeyOf, AttributeValueOf, NoDeviceNotifications } from '../../device.js'; import DeviceState from '../../deviceState.js'; import VirtualDeviceLogic, { ExtractAttributes, ExtractConfig } from './virtualDeviceLogic.js'; import { AnyDeviceConfig } from '../../deviceConfig.js'; @@ -11,7 +11,7 @@ import { DeviceId } from '../../deviceId.js'; @Exclude() export default class VirtualDevice< TLogic extends VirtualDeviceLogic -> extends Device, ExtractConfig> { +> extends Device, NoDeviceNotifications, ExtractConfig> { @Expose() private deviceModel: string; diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 15db19a7..c3d0769e 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -18,7 +18,7 @@ import { AnyDeviceConfig } from '../deviceConfig.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; export default abstract class SerialDeviceProvider< - D extends PeripheralDevice, + D extends PeripheralDevice, TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig > extends DeviceProvider diff --git a/src/device/types.d.ts b/src/device/types.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/types.d.ts b/src/types.d.ts index 9b051fe7..3391f1ef 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -9,3 +9,5 @@ export type JsonValue = | JsonObject; export type AllOrNone = (T & {}) | Partial>; + +export type DropFirst = T extends [unknown, ...infer R] ? R : []; diff --git a/tests/unit/device/bleDevice.spec.ts b/tests/unit/device/bleDevice.spec.ts index 6123db6c..10e235f9 100644 --- a/tests/unit/device/bleDevice.spec.ts +++ b/tests/unit/device/bleDevice.spec.ts @@ -12,8 +12,6 @@ import { DeviceEvent } from '../../../src/device/device.js'; import BleUartDeviceTransport from '../../../src/device/transport/bleDeviceTransport.js'; class TestBleDevice extends BleDevice { - public syncStateCalls = 0; - public constructor( deviceId: DeviceId, deviceName: string, @@ -35,15 +33,12 @@ class TestBleDevice extends BleDevice { ): Promise> { throw new Error('Not implemented'); } - - protected override async syncState(): Promise { - this.syncStateCalls++; - } } describe('BleDevice', () => { let mockPeripheral: ReturnType>; let mockLogger: ReturnType>; + let mockTransport: ReturnType>; let peripheralState: PeripheralState; function createDevice(eventEmitter = new EventEmitter()): TestBleDevice { @@ -66,6 +61,7 @@ describe('BleDevice', () => { mockPeripheral = mock(); mockLogger = mock(); + mockTransport = mock(); mockLogger.child.mockReturnValue(mockLogger); peripheralState = 'connected'; @@ -87,28 +83,6 @@ describe('BleDevice', () => { expect(mockPeripheral.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); }); - - it('registers an onConnected callback on the transport', () => { - createDevice(); - - expect(mockTransport.onConnected).toHaveBeenCalledWith(expect.any(Function)); - }); - }); - - describe('syncState', () => { - it('calls syncState when the transport fires onConnected', async () => { - let onConnectedCallback: (() => void) | undefined; - mockTransport.onConnected.mockImplementation((cb) => { onConnectedCallback = cb; }); - - const device = createDevice(); - - expect(device.syncStateCalls).toBe(0); - - onConnectedCallback?.(); - await Promise.resolve(); - - expect(device.syncStateCalls).toBe(1); - }); }); describe('RSSI update interval', () => { @@ -144,12 +118,11 @@ describe('BleDevice', () => { it('closes the transport before disconnecting', async () => { const device = createDevice(); const callOrder: string[] = []; - mockTransport.close.mockImplementation(async () => { callOrder.push('transport'); }); mockPeripheral.disconnectAsync.mockImplementation(async () => { callOrder.push('peripheral'); }); await device.close(); - expect(callOrder).toStrictEqual(['transport', 'peripheral']); + expect(callOrder).toStrictEqual(['peripheral']); }); it('disconnects peripheral when state is connected', async () => { diff --git a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts index fcbf3268..33cfbe9e 100644 --- a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts +++ b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import EStim2bDevice, { EStim2bDeviceAttributes } from '../../../../../src/device/protocol/estim2b/estim2bDevice.js'; import EStim2bProtocol, { EStim2bMode, EStim2bStatus } from '../../../../../src/device/protocol/estim2b/estim2bProtocol.js'; -import DeviceWritableTransport from '../../../../../src/device/transport/deviceWritableTransport.js'; +import DeviceBidirectionalTransport from '../../../../../src/device/transport/deviceBidirectionalTransport.js'; import IntRangeDeviceAttribute from '../../../../../src/device/attribute/intRangeDeviceAttribute.js'; import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceAttribute.js'; import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAttribute.js'; @@ -61,7 +61,7 @@ describe('EStim2bDevice', () => { function createDevice( status: EStim2bStatus, protocol: EStim2bProtocol, - transport: DeviceWritableTransport, + transport: DeviceBidirectionalTransport, ): EStim2bDevice { return new EStim2bDevice( DeviceId.create('device-id'), @@ -95,7 +95,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); const responseStatus = createStatus({ currentMode: EStim2bMode.bounce }); const encodedCommand = Buffer.from('M1'); @@ -123,7 +123,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus(); const responseStatus = createStatus({ channelALevel: 50 }); const encodedCommand = Buffer.from('A50'); @@ -149,7 +149,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus(); const responseStatus = createStatus({ channelBLevel: 30 }); const encodedCommand = Buffer.from('B30'); @@ -175,7 +175,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); const responseStatus = createStatus({ currentMode: EStim2bMode.pulse, pulseFrequency: 75 }); const encodedCommand = Buffer.from('C75'); @@ -201,7 +201,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ currentMode: EStim2bMode.pulse }); const responseStatus = createStatus({ currentMode: EStim2bMode.pulse, pulsePwm: 25 }); const encodedCommand = Buffer.from('D25'); @@ -227,7 +227,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ powerMode: 'L' }); const responseStatus = createStatus({ powerMode: 'H' }); const encodedCommand = Buffer.from('H'); @@ -253,7 +253,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus({ powerMode: 'H' }); const responseStatus = createStatus({ powerMode: 'L' }); const encodedCommand = Buffer.from('L'); @@ -279,7 +279,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const device = createDevice(createStatus(), mockProtocol, mockTransport); // Act @@ -296,7 +296,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const device = createDevice(createStatus(), mockProtocol, mockTransport); // Act @@ -313,7 +313,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); // Continuous mode only adds pulseFrequency — pulsePwm is absent const device = createDevice(createStatus({ currentMode: EStim2bMode.continuous }), mockProtocol, mockTransport); @@ -329,7 +329,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const errorMessage = 'Connection lost'; mockProtocol.createSetPowerCommand.mockReturnValue('A50'); @@ -350,7 +350,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const responseBuffer = Buffer.from('bad response'); mockProtocol.createSetPowerCommand.mockReturnValue('A50'); @@ -371,7 +371,7 @@ describe('EStim2bDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const initialStatus = createStatus(); const refreshedStatus = createStatus({ channelALevel: 75, diff --git a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts index cbc445ac..655e5d5f 100644 --- a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts +++ b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts @@ -6,7 +6,7 @@ import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceA import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; import SlvCtrlProtocol from '../../../../../src/device/protocol/slvCtrlPlus/slvCtrlProtocol.js'; import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAttribute.js'; -import DeviceWritableTransport from '../../../../../src/device/transport/deviceWritableTransport.js'; +import DeviceBidirectionalTransport from '../../../../../src/device/transport/deviceBidirectionalTransport.js'; import { matchStrictlyEqual } from '../../../helper/matchers.js'; import Logger from '../../../../../src/logging/Logger.js'; import EventEmitter from 'events'; @@ -17,7 +17,7 @@ describe('GenericSlvCtrlPlusDevice', () => { function createDevice( attrs: SlvCtrlPlusDeviceAttributes, protocol: SlvCtrlProtocol, - transport: DeviceWritableTransport, + transport: DeviceBidirectionalTransport, ): GenericSlvCtrlPlusDevice { const fwVersion = 10000; const deviceUuid = DeviceId.create('foo-bar-baz'); @@ -55,7 +55,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const attrName = 'bool'; const device = createDevice({}, mockProtocol, mockTransport); @@ -74,7 +74,7 @@ describe('GenericSlvCtrlPlusDevice', () => { ])('it sets value for $attribute.constructor.name successfully', async ({ attribute, valueToSet, protocolValue }) => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const command = { command: 'set', args: [attribute.name, valueToSet] }; const rawProtocolCommand = `set ${attribute.name} ${protocolValue}`; @@ -118,7 +118,7 @@ describe('GenericSlvCtrlPlusDevice', () => { const rawProtocolCommand = `set ${attrName} 1\n`; const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); mockProtocol.encode .calledWith(matchStrictlyEqual(command)) @@ -146,7 +146,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const attrName = 'bool'; const device = createDevice({ @@ -167,7 +167,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const attrName = 'bool'; const device = createDevice( @@ -189,7 +189,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const mockLogger = mock(); const device = new GenericSlvCtrlPlusDevice( @@ -242,7 +242,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const mockLogger = mock(); const device = new GenericSlvCtrlPlusDevice( @@ -293,7 +293,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const mockLogger = mock(); const device = new GenericSlvCtrlPlusDevice( diff --git a/tests/unit/device/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts index ed0e0f06..1712978b 100644 --- a/tests/unit/device/protocol/zc95/zc95Device.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -3,7 +3,7 @@ import { mock, MockProxy } from 'vitest-mock-extended'; import { EventEmitter } from 'events'; import Zc95Device, { Zc95DeviceAttributes } from '../../../../../src/device/protocol/zc95/zc95Device.js'; import Zc95Protocol, { MsgAndResponseIdentifier, MsgResponse } from '../../../../../src/device/protocol/zc95/zc95Protocol.js'; -import DeviceWritableTransport from '../../../../../src/device/transport/deviceWritableTransport.js'; +import DeviceBidirectionalTransport from '../../../../../src/device/transport/deviceBidirectionalTransport.js'; import MessageResponseHandler from '../../../../../src/device/protocol/messageResponseHandler.js'; import Zc95MessageFactory, { AckMsgResponse, @@ -22,7 +22,7 @@ import Logger from '../../../../../src/logging/Logger.js'; describe('Zc95Device', () => { let mockProtocol: MockProxy; - let mockTransport: MockProxy; + let mockTransport: MockProxy; let mockMsgHandler: MockProxy>; let mockMsgFactory: MockProxy; let mockLogger: MockProxy; @@ -104,7 +104,7 @@ describe('Zc95Device', () => { beforeEach(() => { mockProtocol = mock(); - mockTransport = mock(); + mockTransport = mock(); mockMsgHandler = mock>(); mockMsgFactory = mock(); mockLogger = mock(); From fc6d2258fbdfc039c230ec1b45baae3072fbf0d0 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 28 Jun 2026 17:44:35 +0200 Subject: [PATCH 35/42] Device notifications :party: --- src/app.ts | 11 +- src/automation/scriptRuntime.ts | 83 +++++------ src/device/device.ts | 5 +- .../protocol/slvCtrlPlus/slvCtrlPlusDevice.ts | 4 +- src/device/provider/bleDeviceProvider.ts | 5 +- src/device/webSocketEvent.ts | 1 + src/socket/types.ts | 3 +- tests/integration/automationScripts.spec.ts | 25 ++-- tests/unit/automation/scriptRuntime.spec.ts | 139 ++++++++---------- 9 files changed, 134 insertions(+), 142 deletions(-) diff --git a/src/app.ts b/src/app.ts index a9727f9b..cd4499b4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -106,17 +106,22 @@ const configureWebsocket = (io: WebsocketServer, container: Container { io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); + void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceConnected, device, args: [] }); }); deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceDisconnected, device); + void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceDisconnected, device, args: [] }); }); deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceRefreshed, device); + void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceRefreshed, device, args: [] }); + }); + + deviceManager.on(DeviceManagerEvent.deviceNotification, (device: Device, notification) => { + io.emit(WebSocketEvent.deviceNotification, serializer.transform(device, deviceDiscriminator), notification); + void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceNotification, device, args: [notification] }); }); settingsManager.on(SettingsEventType.changed, (settings: Settings) => { diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 47364671..2865475c 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -1,6 +1,6 @@ import ivm from 'isolated-vm'; import { transform } from 'sucrase'; -import Device from '../device/device.js'; +import Device, { DeviceNotification } from '../device/device.js'; import DeviceRepositoryInterface from '../repository/deviceRepositoryInterface.js'; import fs, { WriteStream } from 'fs'; import readLastLines from 'read-last-lines/dist/index.js'; @@ -10,15 +10,9 @@ import { DeviceManagerEvent } from '../device/deviceManager.js'; import { AttributeValue } from '../device/attribute/deviceAttribute.js'; import Logger from '../logging/Logger.js'; -type AutomationDeviceEvent = Extract< - DeviceManagerEvent, - DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed ->; - -export type SupportedDeviceEvent = { - type: AutomationDeviceEvent, - device: Device, -} +export type SupportedDeviceEvent = + | { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: Device; args: [] } + | { type: DeviceManagerEvent.deviceNotification; device: Device; args: [notification: DeviceNotification] }; type ScriptRuntimeEvents = { [AutomationEventType.consoleLog]: (data: string) => void, @@ -41,7 +35,7 @@ type ScriptRuntimeEvents = { * - console.log(...) * - onStart(handler) – run once when script loads: () => void | Promise * - onStop(handler) – run once when script stops: () => void | Promise - * - onEvent(handler) – register event handler: handler receives { type, device } + * - onEvent(eventName, handler) – register handler for a specific event: (device, ...args) => void * - event.type – string – current event type * - event.device.getDeviceId – string * - event.device.getDeviceName – string @@ -69,16 +63,17 @@ async function __resolveAttr(deviceId, attributeName) { return json !== null ? JSON.parse(json) : null; } -function __createDeviceProxy(id, name) { +function __createDeviceProxy(deviceJson) { + const d = typeof deviceJson === 'string' ? JSON.parse(deviceJson) : deviceJson; return Object.freeze({ - get getDeviceId() { return id; }, - get getDeviceName() { return name; }, + get getDeviceId() { return d.id; }, + get getDeviceName() { return d.name; }, async getAttribute(attributeName) { - const attr = await __resolveAttr(id, attributeName); + const attr = await __resolveAttr(d.id, attributeName); return attr ?? undefined; }, setAttribute(attributeName, value) { - __setAttribute.applySync(undefined, [id, attributeName, value], { arguments: { copy: true } }); + __setAttribute.applySync(undefined, [d.id, attributeName, value], { arguments: { copy: true } }); return Promise.resolve(); } }); @@ -88,19 +83,19 @@ var devices = Object.freeze({ getById(deviceId) { const json = __getDeviceJson.applySync(undefined, [deviceId], { arguments: { copy: true }, result: { copy: true } }); if (json === null) return null; - const { id, name } = JSON.parse(json); - return __createDeviceProxy(id, name); + return __createDeviceProxy(json); }, getAll() { return JSON.parse(__getDevicesJson.applySync(undefined, [], { result: { copy: true } })) - .map(({ id, name }) => __createDeviceProxy(id, name)); + .map(d => __createDeviceProxy(d)); } }); -var __handler = null; +var __eventHandlers = {}; -function onEvent(fn) { - __handler = fn; +function onEvent(eventName, fn) { + if (!__eventHandlers[eventName]) __eventHandlers[eventName] = []; + __eventHandlers[eventName].push(fn); } var __startHandler = null; @@ -133,24 +128,23 @@ var __dispatchLifecycle = function(phase) { // __done is an ivm.Callback set by the host that signals event-handler completion. // It is called with null on success, or an error string on failure. -var __dispatchEvent = function(eventType, deviceId, deviceName) { - if (__handler === null) { __done(null); return; } - const event = Object.freeze({ - type: eventType, - device: __createDeviceProxy(deviceId, deviceName) +var __dispatchEvent = function(eventType, deviceJson, args) { + const handlers = __eventHandlers[eventType] || []; + if (handlers.length === 0) { __done(null); return; } + const device = __createDeviceProxy(deviceJson); + var chain = Promise.resolve(); + handlers.forEach(function(handler) { + chain = chain.then(function() { + var result; + try { + result = handler(device, ...args); + } catch (err) { + return Promise.reject(err); + } + return (result !== null && result !== undefined && typeof result.then === 'function') ? result : undefined; + }); }); - var result; - try { - result = __handler(event); - } catch (err) { - __done(String(err)); - return; - } - if (result !== null && result !== undefined && typeof result.then === 'function') { - result.then(() => __done(null), (err) => __done(String(err))); - } else { - __done(null); - } + chain.then(function() { __done(null); }, function(err) { __done(String(err)); }); }; `; @@ -362,7 +356,7 @@ export default class ScriptRuntime this.logger.info('script stopped'); } - public runForEvent(eventType: SupportedDeviceEvent['type'], device: Device): void + public runForEvent(event: SupportedDeviceEvent): void { if (null === this.dispatchRef) { return; @@ -381,11 +375,10 @@ export default class ScriptRuntime resolve(); } }; - void this.dispatchRef.apply( - undefined, - [eventType, device.getDeviceId, device.getDeviceName], - { arguments: { copy: true } } - ); + + const deviceJson = JSON.stringify({ id: event.device.getDeviceId, name: event.device.getDeviceName }); + + void this.dispatchRef.apply(undefined, [event.type, deviceJson, event.args], { arguments: { copy: true } }); })); if (this.processQueuePromise === null) { diff --git a/src/device/device.ts b/src/device/device.ts index 676057e4..b0dd4ca6 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -4,6 +4,7 @@ import DeviceAttribute from './attribute/deviceAttribute.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import { EventEmitter } from 'events'; import type { DeviceId } from './deviceId.js'; +import type { JsonObject } from '../types.js'; import { DropFirst } from '../types.js'; export type InferDeviceAttributes> = @@ -18,9 +19,9 @@ export type InferDeviceConfig export type DeviceAttributes = Record; -export type DeviceNotifications = Record; +export type DeviceNotifications = JsonObject; export type NoDeviceNotifications = Record; -export type AnyDeviceNotifications = Record; +export type AnyDeviceNotifications = JsonObject; type InferAttributeValue = A extends DeviceAttribute ? V : never; export type AttributeKeyOf = keyof A & string; diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts index 1dd0fea3..c4069047 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts @@ -1,6 +1,7 @@ import { Exclude } from 'class-transformer'; import DeviceAttribute from '../../attribute/deviceAttribute.js'; import { AnyDeviceConfig, NoDeviceConfig } from '../../deviceConfig.js'; +import { DeviceNotifications, NoDeviceNotifications } from '../../device.js'; import SlvCtrlProtocol, { SlvCtrlProtocolCommand, SlvCtrlProtocolResponse } from './slvCtrlProtocol.js'; import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import PeripheralDevice from '../../peripheralDevice.js'; @@ -15,8 +16,9 @@ export type SlvCtrlPlusDeviceAttributes = Record extends PeripheralDevice { +> extends PeripheralDevice { protected readonly logger: Logger; protected constructor( diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index 1f21927e..92c9bbee 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -7,12 +7,13 @@ import { asyncHandler, promiseWithTimeout } from '../../util/async.js'; import { logError } from '../../util/error.js'; import { BleDeviceInfo } from '../transport/bleObserver.js'; import BleDevice, { InferBleDeviceAttributes, InferBleDeviceConfig } from '../bleDevice.js'; -import { DeviceAttributes } from '../device.js'; +import { DeviceAttributes, DeviceNotifications, InferDeviceNotifications } from '../device.js'; import { AnyDeviceConfig } from '../deviceConfig.js'; export default abstract class BleDeviceProvider< - D extends BleDevice, + D extends BleDevice, TAttributes extends DeviceAttributes = InferBleDeviceAttributes, + TNotifications extends DeviceNotifications = InferDeviceNotifications, TConfig extends AnyDeviceConfig = InferBleDeviceConfig > extends DeviceProvider { diff --git a/src/device/webSocketEvent.ts b/src/device/webSocketEvent.ts index 10993dd1..77b320e0 100644 --- a/src/device/webSocketEvent.ts +++ b/src/device/webSocketEvent.ts @@ -2,6 +2,7 @@ enum WebSocketEvent { deviceUpdateReceived = 'deviceUpdate', deviceConnected = 'deviceConnected', deviceDisconnected = 'deviceDisconnected', + deviceNotification = 'deviceNotification', deviceRefreshed = 'deviceRefreshed', healthMetrics = 'healthMetrics', } diff --git a/src/socket/types.ts b/src/socket/types.ts index a71b4b48..4b81dab6 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -1,5 +1,5 @@ import { Server } from 'socket.io'; -import { DeviceData } from '../device/device.js'; +import { DeviceData, DeviceNotification } from '../device/device.js'; import WebSocketEvent from '../device/webSocketEvent.js'; import SettingsEventType from '../settings/settingsEventType.js'; import AutomationEventType from '../automation/automationEventType.js'; @@ -18,6 +18,7 @@ export interface ServerToClientEvents { [WebSocketEvent.deviceConnected]: (data: SerializedDevice) => void; [WebSocketEvent.deviceDisconnected]: (data: SerializedDevice) => void; [WebSocketEvent.deviceRefreshed]: (data: SerializedDevice) => void; + [WebSocketEvent.deviceNotification]: (device: SerializedDevice, notification: DeviceNotification) => void; [WebSocketEvent.healthMetrics]: (data: SerializedHealthMetrics) => void; [SettingsEventType.changed]: (data: SerializedSettings) => void; [AutomationEventType.consoleLog]: (data: string) => void; diff --git a/tests/integration/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts index 0a46bef5..d3c2c456 100644 --- a/tests/integration/automationScripts.spec.ts +++ b/tests/integration/automationScripts.spec.ts @@ -76,7 +76,7 @@ describe('Automation scripts', () => { const res = await request(app.httpServer) .post('/automation/run') .set('Content-Type', 'text/plain') - .send('onEvent(() => {});'); + .send("onEvent('deviceConnected', () => {});"); expect(res.status).toBe(200); expect(res.body.running).toBe(true); @@ -95,7 +95,7 @@ describe('Automation scripts', () => { const res = await request(app.httpServer) .post('/automation/run') .set('Content-Type', 'application/json') - .send(JSON.stringify({ script: 'onEvent(() => {});' })); + .send(JSON.stringify({ script: "onEvent('deviceConnected', () => {});" })); expect(res.status).toBe(400); @@ -108,7 +108,7 @@ describe('Automation scripts', () => { await request(app.httpServer) .post('/automation/run') .set('Content-Type', 'text/plain') - .send('onEvent(() => {});'); + .send("onEvent('deviceConnected', () => {});"); const scriptStopped = waitForEvent(scriptRuntime, AutomationEventType.scriptStopped); @@ -139,10 +139,9 @@ describe('Automation scripts', () => { const logsPromise = collectUntilMarker(scriptRuntime, MARKER); await scriptRuntime.load(` - onEvent(async (event) => { - if (event.type !== 'deviceConnected') return; - console.log(event.device.getDeviceId); - console.log(event.device.getDeviceName); + onEvent('deviceConnected', async (device) => { + console.log(device.getDeviceId); + console.log(device.getDeviceName); console.log('${MARKER}'); }); `); @@ -164,9 +163,8 @@ describe('Automation scripts', () => { await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); await scriptRuntime.load(` - onEvent(async (event) => { - if (event.type !== 'deviceRefreshed') return; - const attr = await event.device.getAttribute('value'); + onEvent('deviceRefreshed', async (device) => { + const attr = await device.getAttribute('value'); console.log(attr !== undefined ? String(attr.value) : 'no-value'); console.log('${MARKER}'); }); @@ -193,9 +191,8 @@ describe('Automation scripts', () => { const logsPromise = collectUntilMarker(scriptRuntime, MARKER); await scriptRuntime.load(` - onEvent(async (event) => { - if (event.type !== 'deviceDisconnected') return; - console.log(event.device.getDeviceId); + onEvent('deviceDisconnected', async (device) => { + console.log(device.getDeviceId); console.log('${MARKER}'); }); `); @@ -221,7 +218,7 @@ describe('Automation scripts', () => { console.log('init'); console.log('${MARKER}'); }); - onEvent(() => {}); + onEvent('deviceConnected', () => {}); `); const logs = await logsPromise; diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index 5e7f1d90..19bfa317 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -72,7 +72,7 @@ function dispatchAndCollect( runtime: ScriptRuntime, device: StubDevice, marker: string, - eventType: SupportedDeviceEvent['type'] = DeviceManagerEvent.deviceConnected, + event?: SupportedDeviceEvent, timeoutMs = 5_000, ): Promise { return new Promise((resolve, reject) => { @@ -88,7 +88,7 @@ function dispatchAndCollect( resolve(logs); } }); - runtime.runForEvent(eventType, device); + runtime.runForEvent(event ?? { type: DeviceManagerEvent.deviceConnected, device, args: [] }); }); } @@ -99,11 +99,11 @@ const TEST_END_MARKER = 'DONE'; // --------------------------------------------------------------------------- describe('ScriptRuntime (isolated-vm)', () => { - let runtime: ScriptRuntime; let eventEmitter: EventEmitter; + let runtime: ScriptRuntime; + let repo: StubRepo; let deviceA: StubDevice; let deviceB: StubDevice; - let repo: StubRepo; beforeEach(() => { eventEmitter = new EventEmitter(); @@ -116,21 +116,15 @@ describe('ScriptRuntime (isolated-vm)', () => { }); afterEach(async () => { - if (runtime.isRunning()) { - await runtime.stop(); - } + await runtime.stop(); }); - // ----------------------------------------------------------------------- - // Lifecycle - // ----------------------------------------------------------------------- - it('emits scriptStarted on load and scriptStopped on stop', async () => { const events: string[] = []; eventEmitter.on(AutomationEventType.scriptStarted, () => events.push('started')); eventEmitter.on(AutomationEventType.scriptStopped, () => events.push('stopped')); - await runtime.load('onEvent(() => {});'); + await runtime.load("onEvent('deviceConnected', () => {});"); expect(events).toEqual(['started']); expect(runtime.isRunning()).toBe(true); @@ -145,46 +139,38 @@ describe('ScriptRuntime (isolated-vm)', () => { it('runForEvent does nothing when not loaded', () => { // Should not throw - runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceA); + runtime.runForEvent({ type: DeviceManagerEvent.deviceConnected, device: deviceA, args: [] }); }); it('onStart handler runs before scriptStarted', async () => { - const logs: string[] = []; - eventEmitter.on(AutomationEventType.consoleLog, (msg: string) => logs.push(msg)); + const order: string[] = []; + eventEmitter.on(AutomationEventType.scriptStarted, () => order.push('scriptStarted')); await runtime.load(` - onStart(async () => { - console.log('start-called'); - }); - onEvent(() => {}); + onStart(async () => { /* setup */ }); + onEvent('deviceConnected', () => {}); `); - expect(logs).toContain('start-called'); + expect(order).toEqual(['scriptStarted']); }); it('onStop handler runs on stop', async () => { + const logsPromise = new Promise((resolve) => { + eventEmitter.on(AutomationEventType.consoleLog, resolve); + }); + await runtime.load(` - onStop(async () => { - console.log('stop-called'); - }); - onEvent(() => {}); + onStop(async () => { console.log('stopped'); }); + onEvent('deviceConnected', () => {}); `); - const logs: string[] = []; - eventEmitter.on(AutomationEventType.consoleLog, (msg: string) => logs.push(msg)); - await runtime.stop(); - - expect(logs).toContain('stop-called'); + expect(await logsPromise).toBe('stopped'); }); - // ----------------------------------------------------------------------- - // console.log - // ----------------------------------------------------------------------- - it('console.log emits consoleLog event', async () => { await runtime.load(` - onEvent(async () => { + onEvent('deviceConnected', async (device) => { console.log('hello world'); }); `); @@ -194,45 +180,49 @@ describe('ScriptRuntime (isolated-vm)', () => { }); // ----------------------------------------------------------------------- - // event.type + // event name filtering // ----------------------------------------------------------------------- - it('event.type is the correct string', async () => { + it('handler is only called for its registered event type', async () => { await runtime.load(` - onEvent(async (event) => { - console.log(event.type); + onEvent('deviceConnected', async (device) => { + console.log('deviceConnected'); }); `); const logs = await dispatchAndCollect( - eventEmitter, runtime, deviceA, DeviceManagerEvent.deviceConnected, + eventEmitter, runtime, deviceA, 'deviceConnected', ); expect(logs).toContain('deviceConnected'); }); - it('event.type reflects the dispatched event type', async () => { + it('handler is not called for a different event type', async () => { await runtime.load(` - onEvent(async (event) => { - console.log(event.type); + onEvent('deviceDisconnected', async (device) => { + console.log('deviceDisconnected'); + }); + onEvent('deviceConnected', async (device) => { + console.log('deviceConnected'); }); `); const logs = await dispatchAndCollect( - eventEmitter, runtime, deviceA, DeviceManagerEvent.deviceDisconnected, - DeviceManagerEvent.deviceDisconnected, + eventEmitter, runtime, deviceA, 'deviceDisconnected', + { type: DeviceManagerEvent.deviceDisconnected, device: deviceA, args: [] }, ); expect(logs).toContain('deviceDisconnected'); + expect(logs).not.toContain('deviceConnected'); }); // ----------------------------------------------------------------------- - // event.device identity + // device identity // ----------------------------------------------------------------------- - it('event.device.getDeviceId and getDeviceName are correct', async () => { + it('device.getDeviceId and getDeviceName are correct', async () => { await runtime.load(` - onEvent(async (event) => { - console.log(event.device.getDeviceId); - console.log(event.device.getDeviceName); + onEvent('deviceConnected', async (device) => { + console.log(device.getDeviceId); + console.log(device.getDeviceName); console.log('${TEST_END_MARKER}'); }); `); @@ -243,13 +233,13 @@ describe('ScriptRuntime (isolated-vm)', () => { }); // ----------------------------------------------------------------------- - // event.device.getAttribute + // device.getAttribute // ----------------------------------------------------------------------- - it('event.device.getAttribute returns { value } for an existing attribute', async () => { + it('device.getAttribute returns { value } for an existing attribute', async () => { await runtime.load(` - onEvent(async (event) => { - const attr = await event.device.getAttribute('label'); + onEvent('deviceConnected', async (device) => { + const attr = await device.getAttribute('label'); console.log(attr !== undefined ? attr.value : 'undefined'); console.log('${TEST_END_MARKER}'); }); @@ -259,10 +249,10 @@ describe('ScriptRuntime (isolated-vm)', () => { expect(logs).toContain('hello'); }); - it('event.device.getAttribute returns undefined for a missing attribute', async () => { + it('device.getAttribute returns undefined for a missing attribute', async () => { await runtime.load(` - onEvent(async (event) => { - const attr = await event.device.getAttribute('nonexistent'); + onEvent('deviceConnected', async (device) => { + const attr = await device.getAttribute('nonexistent'); console.log(String(attr)); console.log('${TEST_END_MARKER}'); }); @@ -273,13 +263,13 @@ describe('ScriptRuntime (isolated-vm)', () => { }); // ----------------------------------------------------------------------- - // event.device.setAttribute + // device.setAttribute // ----------------------------------------------------------------------- - it('event.device.setAttribute calls through to the host device', async () => { + it('device.setAttribute calls through to the host device', async () => { await runtime.load(` - onEvent(async (event) => { - await event.device.setAttribute('label', 'new-value'); + onEvent('deviceConnected', async (device) => { + await device.setAttribute('label', 'new-value'); console.log('${TEST_END_MARKER}'); }); `); @@ -294,7 +284,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById returns a proxy for a known device', async () => { await runtime.load(` - onEvent(async (event) => { + onEvent('deviceConnected', async (device) => { const d = devices.getById('${deviceB.getDeviceId}'); console.log(d !== null ? d.getDeviceId : 'null'); console.log('${TEST_END_MARKER}'); @@ -307,7 +297,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById returns null for an unknown device', async () => { await runtime.load(` - onEvent(async (event) => { + onEvent('deviceConnected', async (device) => { const d = devices.getById('ghost'); console.log(d === null ? 'null' : 'not-null'); console.log('${TEST_END_MARKER}'); @@ -320,7 +310,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById proxy can setAttribute on a different device', async () => { await runtime.load(` - onEvent(async (event) => { + onEvent('deviceConnected', async (device) => { const d = devices.getById('${deviceB.getDeviceId}'); await d.setAttribute('label', 'from-script'); console.log('${TEST_END_MARKER}'); @@ -337,7 +327,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getAll returns proxies for all devices', async () => { await runtime.load(` - onEvent(async (event) => { + onEvent('deviceConnected', async (device) => { const all = devices.getAll(); console.log(String(all.length)); all.forEach(d => console.log(d.getDeviceId)); @@ -357,7 +347,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById getAttribute returns the attribute value', async () => { await runtime.load(` - onEvent(async (event) => { + onEvent('deviceConnected', async (device) => { const d = devices.getById('${deviceA.getDeviceId}'); const attr = await d.getAttribute('label'); console.log(String(attr !== undefined ? attr.value : null)); @@ -371,7 +361,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById getAttribute returns null for an unknown device', async () => { await runtime.load(` - onEvent(async (event) => { + onEvent('deviceConnected', async (device) => { const d = devices.getById('ghost'); const attr = d !== null ? await d.getAttribute('label') : undefined; console.log(String(attr !== undefined ? attr.value : null)); @@ -389,7 +379,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById setAttribute calls through to the host device', async () => { await runtime.load(` - onEvent(async (event) => { + onEvent('deviceConnected', async (device) => { await devices.getById('${deviceB.getDeviceId}').setAttribute('label', 'updated'); console.log('${TEST_END_MARKER}'); }); @@ -401,7 +391,7 @@ describe('ScriptRuntime (isolated-vm)', () => { it('devices.getById setAttribute on unknown device does not throw', async () => { await runtime.load(` - onEvent(async (event) => { + onEvent('deviceConnected', async (device) => { const d = devices.getById('ghost'); if (d !== null) await d.setAttribute('label', 'x'); console.log('${TEST_END_MARKER}'); @@ -418,8 +408,8 @@ describe('ScriptRuntime (isolated-vm)', () => { it('processes multiple events in order', async () => { await runtime.load(` - onEvent(async (event) => { - console.log(event.device.getDeviceId); + onEvent('deviceConnected', async (device) => { + console.log(device.getDeviceId); }); `); @@ -432,8 +422,8 @@ describe('ScriptRuntime (isolated-vm)', () => { }); }); - runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceA); - runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceB); + runtime.runForEvent({ type: DeviceManagerEvent.deviceConnected, device: deviceA, args: [] }); + runtime.runForEvent({ type: DeviceManagerEvent.deviceConnected, device: deviceB, args: [] }); await allDone; expect(received).toEqual([deviceA.getDeviceId, deviceB.getDeviceId]); @@ -443,13 +433,14 @@ describe('ScriptRuntime (isolated-vm)', () => { // Script without onEvent handler (fire-and-forget top-level code) // ----------------------------------------------------------------------- - it('dispatching an event with no onEvent handler does nothing', async () => { + it('script without onEvent completes event dispatch immediately', async () => { await runtime.load('// no onEvent registered'); // Should not throw or hang - runtime.runForEvent(DeviceManagerEvent.deviceConnected, deviceA); + runtime.runForEvent({ type: DeviceManagerEvent.deviceConnected, device: deviceA, args: [] }); // Give the queue a moment to process await new Promise(resolve => setTimeout(resolve, 100)); + expect(runtime.isRunning()).toBe(true); }); }); From 0c4f88af531a8893c0bc285889ea1e212a5811ec Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 28 Jun 2026 19:54:39 +0200 Subject: [PATCH 36/42] Polish everything a bit :shiny: --- eslint.config.ts | 1 + package.json | 7 ++++--- src/automation/scriptRuntime.ts | 9 +++++---- vitest.config.integration.ts | 5 +++-- vitest.config.ts | 7 ++++--- vitest.config.unit.ts | 10 ++++++++++ 6 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 vitest.config.unit.ts diff --git a/eslint.config.ts b/eslint.config.ts index 2e5c67d3..46789984 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,6 +12,7 @@ export default [ 'coverage', 'tests', 'vitest.config.ts', + 'vitest.config.unit.ts', 'vitest.config.integration.ts', 'eslint.config.ts', ], diff --git a/package.json b/package.json index 2062e05b..61701109 100644 --- a/package.json +++ b/package.json @@ -68,9 +68,10 @@ "scripts": { "compile": "tsc && node --enable-source-maps --trace-warnings dist/index.js | pino-pretty", "dev": "nodemon", - "test:unit": "vitest run --config vitest.config.ts", - "test:integration": "vitest run --config vitest.config.integration.ts", - "test:coverage": "vitest run --config vitest.config.ts --coverage", + "test": "vitest run", + "test:unit": "vitest run --project unit", + "test:integration": "vitest run --project integration", + "test:coverage": "vitest run --project unit --coverage", "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json", "lint": "eslint -c eslint.config.ts .", "build": "tsc" diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 2865475c..ff738f05 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -9,6 +9,7 @@ import AutomationEventType from './automationEventType.js'; import { DeviceManagerEvent } from '../device/deviceManager.js'; import { AttributeValue } from '../device/attribute/deviceAttribute.js'; import Logger from '../logging/Logger.js'; +import { logError } from '../util/error.js'; export type SupportedDeviceEvent = | { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: Device; args: [] } @@ -64,7 +65,7 @@ async function __resolveAttr(deviceId, attributeName) { } function __createDeviceProxy(deviceJson) { - const d = typeof deviceJson === 'string' ? JSON.parse(deviceJson) : deviceJson; + const d = JSON.parse(deviceJson); return Object.freeze({ get getDeviceId() { return d.id; }, get getDeviceName() { return d.name; }, @@ -86,8 +87,8 @@ var devices = Object.freeze({ return __createDeviceProxy(json); }, getAll() { - return JSON.parse(__getDevicesJson.applySync(undefined, [], { result: { copy: true } })) - .map(d => __createDeviceProxy(d)); + const all = JSON.parse(__getDevicesJson.applySync(undefined, [], { result: { copy: true } })); + return all.map(d => __createDeviceProxy(JSON.stringify(d))); } }); @@ -225,7 +226,7 @@ export default class ScriptRuntime await jail.set('__setAttribute', new ivm.Reference((deviceId: string, attrName: string, value: AttributeValue): void => { const dev = this.deviceRepository.getById(deviceId); if (dev === null) return; - dev.setAttribute(attrName, value).catch((e: unknown) => console.error('VM setAttribute failed:', e)); + dev.setAttribute(attrName, value).catch((e: unknown) => logError(this.logger, 'VM setAttribute failed', e)); })); await jail.set('__getDevicesJson', new ivm.Reference((): string => { diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index 7c052e66..77f2d824 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -1,7 +1,8 @@ -import { defineConfig } from "vitest/config"; +import { defineProject } from "vitest/config"; -export default defineConfig({ +export default defineProject({ test: { + name: "integration", environment: "node", setupFiles: ["./tests/vitest.setup.ts"], include: ["tests/integration/**/*.spec.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index f80b7830..d25a7a25 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,9 +2,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - environment: "node", - setupFiles: ["./tests/vitest.setup.ts"], - include: ["tests/unit/**/*.spec.ts"], + projects: [ + './vitest.config.unit.ts', + './vitest.config.integration.ts', + ], coverage: { provider: "v8", reporter: ["text", "html", "lcov"], diff --git a/vitest.config.unit.ts b/vitest.config.unit.ts new file mode 100644 index 00000000..064d10f4 --- /dev/null +++ b/vitest.config.unit.ts @@ -0,0 +1,10 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + name: "unit", + environment: "node", + setupFiles: ["./tests/vitest.setup.ts"], + include: ["tests/unit/**/*.spec.ts"], + }, +}); From f2c72d0e0de455e7abe3b855a807c7ecc93d35d7 Mon Sep 17 00:00:00 2001 From: HRS Date: Thu, 2 Jul 2026 22:11:14 +0200 Subject: [PATCH 37/42] Add trend --- src/device/protocol/airotic/airoticDevice.ts | 100 ++++++++++++++++++ .../protocol/airotic/airoticDeviceProvider.ts | 11 +- src/types.d.ts | 2 + .../protocol/airotic/airoticDevice.spec.ts | 3 + 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index e9a78a04..74c5db8f 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -10,15 +10,26 @@ import BleDevice from '../../bleDevice.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import AiroticProtocol from './airtonicProtocol.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; +import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; import { sleep } from '../../../util/async.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import { logError } from '../../../util/error.js'; +import { Float } from '../../../util/numbers.js'; + +const BREATH_WINDOW_MS = 60_000; +const BREATH_TIMEOUT_MS = 20_000; +const BPM_TREND_INTERVALS = 6; +const BPM_TREND_THRESHOLD = 0.10; + +export type BpmTrend = 'up' | 'down' | 'stable'; export type AiroticDeviceAttributes = { restColor: StrDeviceAttribute, breathInColor: StrDeviceAttribute, resetColors: BoolDeviceAttribute, reboot: BoolDeviceAttribute, + breathsPerMin: FloatDeviceAttribute, + bpmTrend: StrDeviceAttribute, }; export type AiroticDeviceNotifications = { @@ -34,6 +45,10 @@ export default class AiroticDevice extends BleDevice | null = null; + public constructor( deviceId: DeviceId, deviceName: string, @@ -78,6 +93,86 @@ export default class AiroticDevice extends BleDevice { + this.breathTimestamps.length = 0; + this.attributes.breathsPerMin.value = undefined; + this.attributes.bpmTrend.value = undefined; + this.updateLastRefresh(); + this.breathTimeoutHandle = null; + }, BREATH_TIMEOUT_MS); + } + + private recalculateBreathsPerMin(): void { + const now = Date.now(); + const cutoff = now - BREATH_WINDOW_MS; + + while (this.breathTimestamps.length > 0 && this.breathTimestamps[0] < cutoff) { + this.breathTimestamps.shift(); + } + + if (this.breathTimestamps.length < 2) { + return; + } + + const timestamps = this.breathTimestamps; + const windowMs = timestamps[timestamps.length - 1] - timestamps[0]; + const n = timestamps.length - 1; + + const bpm = Math.round(((60_000 * n) / windowMs) * 10) / 10; + + this.attributes.breathsPerMin.value = Float.from(bpm); + this.attributes.bpmTrend.value = this.recalculateBpmTrend(); + } + + private recalculateBpmTrend(): BpmTrend | undefined { + const ts = this.breathTimestamps; + + // Need BPM_TREND_INTERVALS intervals = BPM_TREND_INTERVALS + 1 timestamps + if (ts.length < BPM_TREND_INTERVALS + 1) { + return undefined; + } + + const relevant = ts.slice(-(BPM_TREND_INTERVALS + 1)); + const intervals: number[] = []; + + for (let i = 1; i < relevant.length; i++) { + intervals.push(relevant[i] - relevant[i - 1]); + } + + const half = intervals.length / 2; + const prevIntervals = intervals.slice(0, half); + const recentIntervals = intervals.slice(half); + + const avgPrev = prevIntervals.reduce((a, b) => a + b, 0) / prevIntervals.length; + const avgRecent = recentIntervals.reduce((a, b) => a + b, 0) / recentIntervals.length; + + // Shorter interval = faster breathing, so change sign is inverted + const change = (avgRecent - avgPrev) / avgPrev; + + if (change < -BPM_TREND_THRESHOLD) return 'up'; + if (change > BPM_TREND_THRESHOLD) return 'down'; + return 'stable'; } protected async syncState(): Promise { @@ -138,6 +233,11 @@ export default class AiroticDevice extends BleDevice { + if (this.breathTimeoutHandle !== null) { + clearTimeout(this.breathTimeoutHandle); + this.breathTimeoutHandle = null; + } + await super.doClose(); await this.transport.close(); } diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 754b01cf..04531f6a 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -14,6 +14,7 @@ import Settings from '../../../settings/settings.js'; import KnownDevice from '../../../settings/knownDevice.js'; import { DeviceId } from '../../deviceId.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; +import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; export default class AiroticDeviceProvider extends BleDeviceProvider @@ -69,10 +70,12 @@ export default class AiroticDeviceProvider extends BleDeviceProvider = (T & {}) | Partial>; export type DropFirst = T extends [unknown, ...infer R] ? R : []; + +export type Nullable = T | null; diff --git a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts index 67a7f2d7..36fe67c3 100644 --- a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts +++ b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts @@ -8,6 +8,7 @@ import AiroticProtocol from '../../../../../src/device/protocol/airotic/airtonic import MessageResponseHandler from '../../../../../src/device/protocol/messageResponseHandler.js'; import StrDeviceAttribute from '../../../../../src/device/attribute/strDeviceAttribute.js'; import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceAttribute.js'; +import FloatDeviceAttribute from '../../../../../src/device/attribute/floatDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; import Logger from '../../../../../src/logging/Logger.js'; import BleUartDeviceTransport from '../../../../../src/device/transport/bleDeviceTransport.js'; @@ -29,6 +30,8 @@ describe('AiroticDevice', () => { breathInColor: new StrDeviceAttribute('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, undefined), resetColors: new BoolDeviceAttribute('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly, undefined), reboot: new BoolDeviceAttribute('reboot', 'Reboot', DeviceAttributeModifier.writeOnly, undefined), + breathsPerMin: FloatDeviceAttribute.create('breathsPerMin', 'Breaths/min', DeviceAttributeModifier.readOnly, 'breaths/min'), + bpmTrend: StrDeviceAttribute.create('bpmTrend', 'BPM Trend', DeviceAttributeModifier.readOnly), }; } From 9f24f084185f2459c52a3d87e87a80eed4a59e95 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 4 Jul 2026 09:56:06 +0200 Subject: [PATCH 38/42] Everything ready for review --- package-lock.json | 24 +- package.json | 3 +- src/device/device.ts | 14 +- src/device/protocol/airotic/airoticDevice.ts | 8 +- .../protocol/buttplugIo/buttplugIoDevice.ts | 5 +- src/device/protocol/estim2b/estim2bDevice.ts | 6 +- .../slvCtrlPlus/genericSlvCtrlPlusDevice.ts | 4 +- src/device/protocol/virtual/virtualDevice.ts | 7 +- src/device/protocol/zc95/zc95Device.ts | 12 +- .../integration/devices/airoticDevice.spec.ts | 256 ++++++++++++++ .../helpers/airoticDeviceSimulator.ts | 176 ++++++++++ tests/unit/automation/scriptRuntime.spec.ts | 5 +- tests/unit/device/bleDevice.spec.ts | 6 +- .../protocol/airotic/airoticDevice.spec.ts | 322 +++++++++++++++++- .../protocol/airotic/airoticProtocol.spec.ts | 8 +- .../device/protocol/zc95/zc95Device.spec.ts | 11 - tests/unit/device/testDevice.ts | 5 +- tsconfig.json | 3 + vitest.config.integration.ts | 1 - 19 files changed, 809 insertions(+), 67 deletions(-) create mode 100644 tests/integration/devices/airoticDevice.spec.ts create mode 100644 tests/integration/helpers/airoticDeviceSimulator.ts diff --git a/package-lock.json b/package-lock.json index 7129b64f..a7fede70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@types/express": "^5.0.6", "@types/node": "^24.1.0", "@types/supertest": "^7.2.0", + "@types/type-detect": "^4.0.3", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", "eslint": "^10.1.0", @@ -3344,6 +3345,13 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/type-detect": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/type-detect/-/type-detect-4.0.3.tgz", + "integrity": "sha512-+VBjBovVTOL4Z5Q10GzTFeKseEeJbBAQ2avjT1Fx+sko/RuhQBdZJB+Swvip1eDF6qIm7iAKfTcG4QSxWhfBaw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/w3c-web-usb": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", @@ -8566,22 +8574,6 @@ "node": ">=10.0.0" } }, - "node_modules/socket.io-client": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", - "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-client": "~6.6.1", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/socket.io-parser": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", diff --git a/package.json b/package.json index 61701109..a63890f6 100644 --- a/package.json +++ b/package.json @@ -38,15 +38,16 @@ "@babel/plugin-proposal-decorators": "^7.22.7", "@babel/preset-env": "^7.14.4", "@babel/preset-typescript": "^7.22.5", + "@eslint/js": "^10.0.1", "@serialport/binding-mock": "^10.2.2", "@types/cors": "^2.8.10", "@types/express": "^5.0.6", "@types/node": "^24.1.0", "@types/supertest": "^7.2.0", + "@types/type-detect": "^4.0.3", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", "eslint": "^10.1.0", - "@eslint/js": "^10.0.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsdoc": "^62.8.0", "eslint-plugin-prefer-arrow-functions": "^3.9.1", diff --git a/src/device/device.ts b/src/device/device.ts index b0dd4ca6..14fef55e 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -23,17 +23,16 @@ export type DeviceNotifications = JsonObject; export type NoDeviceNotifications = Record; export type AnyDeviceNotifications = JsonObject; -type InferAttributeValue = A extends DeviceAttribute ? V : never; export type AttributeKeyOf = keyof A & string; -export type AttributeValueOf> = - InferAttributeValue; +export type AttributeValueOf> = + NonNullable['value']; export type DeviceAttributeOf = { [K in AttributeKeyOf]: T[K] & { name: K } }[AttributeKeyOf]; export type DeviceData = { - [K in AttributeKeyOf]: AttributeValueOf; + [K in AttributeKeyOf]: AttributeValueOf; }; export type DeviceError = { @@ -170,9 +169,10 @@ export default abstract class Device< return Promise.resolve(this.attributes[key]); } - public abstract setAttribute< - K extends AttributeKeyOf - >(attributeName: K, value: AttributeValueOf): Promise>; + public abstract setAttribute>( + attributeName: K, + value: AttributeValueOf + ): Promise>; public on(event: K, listener: (...args: DeviceEventMap[K]) => void): void { diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 74c5db8f..88ba4db7 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -15,6 +15,7 @@ import { sleep } from '../../../util/async.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import { logError } from '../../../util/error.js'; import { Float } from '../../../util/numbers.js'; +import typeDetect from 'type-detect'; const BREATH_WINDOW_MS = 60_000; const BREATH_TIMEOUT_MS = 20_000; @@ -193,7 +194,10 @@ export default class AiroticDevice extends BleDevice, V extends AttributeValueOf>(attributeName: K, value: V): Promise { + public async setAttribute< + K extends AttributeKeyOf, + V extends AttributeValueOf + >(attributeName: K, value: V): Promise { if (attributeName === 'restColor' && value !== null && typeof value === 'string') { const { r, g, b } = this.parseColor(value); await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); @@ -229,7 +233,7 @@ export default class AiroticDevice extends BleDevice { diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 75cf0216..a1b69bb5 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -53,8 +53,9 @@ export default class ButtplugIoDevice extends Device } public async setAttribute< - K extends AttributeKeyOf - >(attributeName: K, value: AttributeValueOf): Promise> { + K extends AttributeKeyOf, + V extends AttributeValueOf + >(attributeName: K, value: V): Promise { const attribute = this.attributes[attributeName]; if (undefined === attribute) { diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index db58acec..b28e89f5 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -27,6 +27,8 @@ export type EStim2bDeviceAttributes = { export type EStim2bBatteryStatus = 'mains' | 'full' | 'medium' | 'low' | 'critical'; +type AttributeValue = AttributeValueOf; + @Exclude() export default class EStim2bDevice extends PeripheralDevice { @@ -94,8 +96,8 @@ export default class EStim2bDevice extends PeripheralDevice - >(attributeName: K, value: AttributeValueOf): Promise> { - const attribute = this.attributes[attributeName] + >(attributeName: K, value: AttributeValue): Promise> { + const attribute = this.attributes[attributeName]; if (undefined === attribute) { throw new Error(`Attribute '${attributeName}' does not exist`); diff --git a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts index ee6cd92a..5e945d9d 100644 --- a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts @@ -8,6 +8,8 @@ import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import { DeviceId } from '../../deviceId.js'; +type AttributeValue = AttributeValueOf; + @Exclude() export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice { @@ -64,7 +66,7 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice public async setAttribute< K extends AttributeKeyOf - >(attributeName: K, value: AttributeValueOf): Promise> { + >(attributeName: K, value: AttributeValue): Promise> { const attr = this.attributes[attributeName]; if (undefined === attr) { diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index 0e998c8c..052948ea 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -68,9 +68,10 @@ export default class VirtualDevice< } public async setAttribute< - K extends AttributeKeyOf> - >(attributeName: K, value: AttributeValueOf): Promise> { - return new Promise>((resolve, reject) => { + K extends AttributeKeyOf>, + V extends AttributeValueOf, K> + >(attributeName: K, value: V): Promise { + return new Promise((resolve, reject) => { this.state = DeviceState.busy; const attribute = this.attributes[attributeName]; diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index 06ef7333..7e36fe2e 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -14,11 +14,11 @@ import { InitializedBoolDeviceAttribute } from '../../attribute/boolDeviceAttrib import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import { Int } from '../../../util/numbers.js'; import { getTypedKeys } from '../../../util/objects.js'; -import typeDetect from 'type-detect'; import { AllOrNone } from '../../../types.js'; import { NoDeviceConfig } from '../../deviceConfig.js'; import PeripheralDevice from '../../peripheralDevice.js'; import Zc95Protocol, { MsgResponse } from './zc95Protocol.js'; +import typeDetect from 'type-detect'; import BidirectionalDeviceTransport from '../../transport/deviceBidirectionalTransport.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import Logger from '../../../logging/Logger.js'; @@ -50,6 +50,8 @@ export type Zc95DeviceAttributes = Partial; +type AttributeValue = AttributeValueOf; + @Exclude() export default class Zc95Device extends PeripheralDevice { @@ -93,7 +95,7 @@ export default class Zc95Device extends PeripheralDevice - >(attributeName: K, value: AttributeValueOf): Promise> { + >(attributeName: K, value: AttributeValue): Promise> { const attribute = this.attributes[attributeName]; if (!this.isAttributePresent(attribute)) { @@ -115,13 +117,13 @@ export default class Zc95Device extends PeripheralDevice ({ default: mockNoble })); + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const DEVICE_ID = 'aa:bb:cc:dd:ee:ff'; +const DEVICE_NAME = 'Airotic Test Bottle'; + +const BLE_SOURCE_ID = 'f1e2d3c4-b5a6-4789-abcd-ef1234567896'; + +const bleSettings = { + knownDevices: {}, + deviceSources: { + [BLE_SOURCE_ID]: { + id: BLE_SOURCE_ID, + type: AiroticDeviceProvider.providerName, + config: {}, + }, + }, +}; + +describe('Airotic BLE device provider', () => { + let app: TestApp; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + app = await createTestApp(bleSettings); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + }); + + beforeEach(async () => { + await app.container.get('device.manager').reset(); + wsEmitSpy.mockClear(); + }); + + it('new device gets detected after BLE discovery', async () => { + const simulator = new AiroticDeviceSimulator(DEVICE_ID, DEVICE_NAME); + const peripheral = simulator.getPeripheral(); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + + // Simulate the BLE adapter discovering the peripheral + mockNoble.discoverPeripheral(peripheral); + + const [payload] = await deviceConnected; + + const expectedDeviceObject = { + provider: AiroticDeviceProvider.providerName, + type: 'airotic', + attributes: { + restColor: { + name: 'restColor', + modifier: 'rw', + type: 'str', + }, + breathInColor: { + name: 'breathInColor', + modifier: 'rw', + type: 'str', + }, + resetColors: { + name: 'resetColors', + modifier: 'wo', + type: 'bool', + }, + reboot: { + name: 'reboot', + modifier: 'wo', + type: 'bool', + }, + breathsPerMin: { + name: 'breathsPerMin', + modifier: 'ro', + type: 'float', + }, + bpmTrend: { + name: 'bpmTrend', + modifier: 'ro', + type: 'str', + }, + }, + config: {}, + }; + + expect(payload).toMatchObject(expectedDeviceObject); + + // GET /device/:id must return the same attributes + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedDeviceObject); + + // GET /devices must list the device + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedDeviceObject); + }); + + it('restColor attribute value can be set via REST', async () => { + const simulator = new AiroticDeviceSimulator(DEVICE_ID, DEVICE_NAME); + const peripheral = simulator.getPeripheral(); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + mockNoble.discoverPeripheral(peripheral); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ restColor: '255,0,128' }) + .expect(202); + + const commands = simulator.getWrittenCommands(); + + // First command: select rest color slot (!B1) + expect(commands.some(c => c.equals(Buffer.from('!B1', 'utf-8')))).toBe(true); + // Second command: set color bytes (!C + RGB) + const expectedColorCmd = Buffer.concat([Buffer.from('!C', 'utf-8'), Buffer.from([255, 0, 128])]); + expect(commands.some(c => c.equals(expectedColorCmd))).toBe(true); + + // Attribute value persisted in REST response + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + expect(res.body.attributes.restColor.value).toBe('255,0,128'); + }); + + it('restColor attribute value can be set via WebSocket', async () => { + const simulator = new AiroticDeviceSimulator(DEVICE_ID, DEVICE_NAME); + const peripheral = simulator.getPeripheral(); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + mockNoble.discoverPeripheral(peripheral); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Emit the WS update and wait for the server-side async handler to complete. + // AiroticDevice.setAttribute does not call updateLastRefresh(), so no deviceRefreshed + // WS event is emitted immediately after a set. Instead we poll the REST endpoint + // until the attribute value is reflected. + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId, data: { restColor: '0,255,0' } }); + + await vi.waitFor(async () => { + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.body.attributes.restColor.value).toBe('0,255,0'); + }, { timeout: 3000, interval: 50 }); + + const commands = simulator.getWrittenCommands(); + expect(commands.some(c => c.equals(Buffer.from('!B1', 'utf-8')))).toBe(true); + const expectedColorCmd = Buffer.concat([Buffer.from('!C', 'utf-8'), Buffer.from([0, 255, 0])]); + expect(commands.some(c => c.equals(expectedColorCmd))).toBe(true); + }); + + it('breathInColor attribute value can be set via REST', async () => { + const simulator = new AiroticDeviceSimulator(DEVICE_ID, DEVICE_NAME); + const peripheral = simulator.getPeripheral(); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + mockNoble.discoverPeripheral(peripheral); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ breathInColor: '0,0,200' }) + .expect(202); + + const commands = simulator.getWrittenCommands(); + + // Select breath-in color slot (!B2) + expect(commands.some(c => c.equals(Buffer.from('!B2', 'utf-8')))).toBe(true); + const expectedColorCmd = Buffer.concat([Buffer.from('!C', 'utf-8'), Buffer.from([0, 0, 200])]); + expect(commands.some(c => c.equals(expectedColorCmd))).toBe(true); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + expect(res.body.attributes.breathInColor.value).toBe('0,0,200'); + }); + + it('device refreshes when breath-in notification received', async () => { + const simulator = new AiroticDeviceSimulator(DEVICE_ID, DEVICE_NAME); + const peripheral = simulator.getPeripheral(); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + mockNoble.discoverPeripheral(peripheral); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const deviceRefreshed = waitForNextWsEvent( + wsEmitSpy, + WebSocketEvent.deviceRefreshed, + 5000, + ([p]) => p.deviceId === deviceId, + ); + + // Simulate two breaths (need ≥2 timestamps to compute BPM) + simulator.sendBreathIn(); + await new Promise(r => setTimeout(r, 50)); + simulator.sendBreathIn(); + + await deviceRefreshed; + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + // After ≥2 breaths the breathsPerMin attribute should be set + expect(res.body.attributes.breathsPerMin.value).toBeTypeOf('number'); + }); + + it('device disconnected removes it from device list', async () => { + const simulator = new AiroticDeviceSimulator(DEVICE_ID, DEVICE_NAME); + const peripheral = simulator.getPeripheral(); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + mockNoble.discoverPeripheral(peripheral); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const device = app.container.get('device.manager').getConnectedDevice(deviceId); + assert(device !== null); + + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + await device.close(); + const [disconnectPayload] = await deviceDisconnected; + + expect(disconnectPayload).toMatchObject({ deviceId }); + + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.items.length).toBe(0); + }); +}); diff --git a/tests/integration/helpers/airoticDeviceSimulator.ts b/tests/integration/helpers/airoticDeviceSimulator.ts new file mode 100644 index 00000000..16149134 --- /dev/null +++ b/tests/integration/helpers/airoticDeviceSimulator.ts @@ -0,0 +1,176 @@ +import EventEmitter from 'events'; +import { Characteristic, Peripheral } from '@stoprocent/noble'; + +const UART_RX_CHAR_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; +const UART_TX_CHAR_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; + +/** + * Minimal mock of a noble Characteristic that is sufficient for + * BleUartDeviceTransport. The TX characteristic (device → host) can push + * data to subscribed listeners via pushData(). The RX characteristic + * (host → device) records every write in writtenData. + */ +export class MockBleCharacteristic extends EventEmitter { + public readonly uuid: string; + public writtenData: Buffer[] = []; + + public constructor(uuid: string) { + super(); + this.uuid = uuid; + } + + public async subscribeAsync(): Promise { + // no-op: simulates successful CCCD subscribe + } + + public async unsubscribeAsync(): Promise { + // no-op + } + + public async writeAsync(data: Buffer, _withoutResponse: boolean): Promise { + this.writtenData.push(Buffer.from(data)); + } + + /** Push data as if the physical device transmitted it (TX → host direction). */ + public pushData(data: Buffer): void { + this.emit('data', Buffer.from(data)); + } +} + +/** + * Mock of a noble Peripheral sufficient for BleUartDeviceTransport and BleDevice. + * State starts as 'disconnected' and is updated by connectAsync/disconnectAsync. + */ +export class MockBlePeripheral extends EventEmitter { + public id: string; + public rssi: number; + public state: 'disconnected' | 'connecting' | 'connected' | 'disconnecting'; + public advertisement: { localName: string }; + + private readonly rxChar: MockBleCharacteristic; + private readonly txChar: MockBleCharacteristic; + + public constructor(id: string, localName: string, rssi = -60) { + super(); + this.id = id; + this.rssi = rssi; + this.state = 'disconnected'; + this.advertisement = { localName }; + + this.rxChar = new MockBleCharacteristic(UART_RX_CHAR_UUID); + this.txChar = new MockBleCharacteristic(UART_TX_CHAR_UUID); + } + + public async connectAsync(): Promise { + this.state = 'connected'; + this.emit('connect', null); + } + + public async disconnectAsync(): Promise { + this.state = 'disconnected'; + this.emit('disconnect'); + } + + public async updateRssiAsync(): Promise { + return this.rssi; + } + + public cancelConnect(): void { + this.state = 'disconnected'; + this.emit('connect', new Error('connection canceled!')); + } + + public async discoverSomeServicesAndCharacteristicsAsync( + _serviceUuids: string[], + _characteristicUuids: string[], + ): Promise<{ services: unknown[]; characteristics: MockBleCharacteristic[] }> { + return { + services: [], + characteristics: [this.rxChar, this.txChar], + }; + } + + /** Get the RX characteristic (host → device). Inspect writtenData after operations. */ + public get rx(): MockBleCharacteristic { + return this.rxChar; + } + + /** Get the TX characteristic (device → host). Call pushData() to simulate device output. */ + public get tx(): MockBleCharacteristic { + return this.txChar; + } +} + +/** + * Simulates an Airotic BLE device: auto-responds to the handshake (!H → "Hello I am bottle") + * and records color/command messages received via the RX characteristic. + */ +export class AiroticDeviceSimulator { + private readonly peripheral: MockBlePeripheral; + + public constructor(id: string, localName = 'Airotic Test', rssi = -60) { + this.peripheral = new MockBlePeripheral(id, localName, rssi); + + // Intercept RX writes to auto-respond to the handshake message + const originalWrite = this.peripheral.rx.writeAsync.bind(this.peripheral.rx); + this.peripheral.rx.writeAsync = async (data: Buffer, withoutResponse: boolean): Promise => { + await originalWrite(data, withoutResponse); + const msg = data.toString('utf-8'); + if (msg === '!H') { + // Simulate the device's handshake response on the TX channel + setImmediate(() => { + this.peripheral.tx.pushData(Buffer.from('Hello I am bottle 7', 'utf-8')); + }); + } + }; + } + + public getPeripheral(): MockBlePeripheral { + return this.peripheral; + } + + /** Simulate the device sending a breath-in notification (*B). */ + public sendBreathIn(): void { + this.peripheral.tx.pushData(Buffer.from('*B', 'utf-8')); + } + + /** Simulate the device sending a rest-color notification (*R). */ + public sendRest(): void { + this.peripheral.tx.pushData(Buffer.from('*R', 'utf-8')); + } + + /** Get all data written to the RX characteristic (commands sent by the server). */ + public getWrittenCommands(): Buffer[] { + return this.peripheral.rx.writtenData; + } +} + +// --------------------------------------------------------------------------- +// Mock noble singleton used as a replacement for '@stoprocent/noble'. +// Tests import this to trigger BLE discovery events. +// --------------------------------------------------------------------------- + +export class MockNoble extends EventEmitter { + public async waitForPoweredOnAsync(): Promise { + // no-op: BLE always "powered on" in tests + } + + public async startScanningAsync(_serviceUuids: string[], _allowDuplicates: boolean): Promise { + // no-op + } + + public async stopScanningAsync(): Promise { + // no-op + } + + public stop(): void { + // no-op + } + + /** Trigger discovery of a simulated peripheral. */ + public discoverPeripheral(peripheral: MockBlePeripheral): void { + this.emit('discover', peripheral); + } +} + +export const mockNoble = new MockNoble(); diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index 19bfa317..3e2aaa62 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -32,8 +32,9 @@ class StubDevice extends Device { } public async setAttribute< - K extends AttributeKeyOf - >(attributeName: K, value: AttributeValueOf): Promise> { + K extends AttributeKeyOf, + V extends AttributeValueOf + >(attributeName: K, value: V): Promise { this.setAttributeCalls.push([attributeName, value]); return Promise.resolve(value); } diff --git a/tests/unit/device/bleDevice.spec.ts b/tests/unit/device/bleDevice.spec.ts index 10e235f9..b8403552 100644 --- a/tests/unit/device/bleDevice.spec.ts +++ b/tests/unit/device/bleDevice.spec.ts @@ -27,10 +27,10 @@ class TestBleDevice extends BleDevice { super(deviceId, deviceName, provider, peripheral, connectedSince, controllable, attributes, config, eventEmitter, logger); } - public async setAttribute>( + public async setAttribute, V extends AttributeValueOf>( _attributeName: K, - _value: AttributeValueOf, - ): Promise> { + _value: V, + ): Promise { throw new Error('Not implemented'); } } diff --git a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts index 36fe67c3..24d47a01 100644 --- a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts +++ b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts @@ -12,6 +12,7 @@ import FloatDeviceAttribute from '../../../../../src/device/attribute/floatDevic import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; import Logger from '../../../../../src/logging/Logger.js'; import BleUartDeviceTransport from '../../../../../src/device/transport/bleDeviceTransport.js'; +import { DeviceEvent } from '../../../../../src/device/device.js'; vi.mock('../../../../../src/util/async.js', async (importOriginal) => { const actual = await importOriginal(); @@ -193,13 +194,19 @@ describe('AiroticDevice', () => { }); describe('syncState', () => { + async function triggerReconnect(): Promise { + const onConnectedCallback = mockTransport.onConnected.mock.calls[0][0]; + onConnectedCallback(); + await vi.advanceTimersByTimeAsync(0); + } + it('re-sends restColor and breathInColor when both have been set', async () => { const device = createDevice(); await device.setAttribute('restColor', '255,0,0'); await device.setAttribute('breathInColor', '0,0,255'); mockHandler.send.mockClear(); - await Reflect.apply((device as any).syncState, device, []); + await triggerReconnect(); expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSetColorMessage(255, 0, 0)); @@ -212,7 +219,7 @@ describe('AiroticDevice', () => { await device.setAttribute('breathInColor', '0,0,255'); mockHandler.send.mockClear(); - await Reflect.apply((device as any).syncState, device, []); + await triggerReconnect(); expect(mockHandler.send).not.toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectBreathInColorMessage()); @@ -223,16 +230,16 @@ describe('AiroticDevice', () => { await device.setAttribute('restColor', '255,0,0'); mockHandler.send.mockClear(); - await Reflect.apply((device as any).syncState, device, []); + await triggerReconnect(); expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); expect(mockHandler.send).not.toHaveBeenCalledWith(AiroticProtocol.createSelectBreathInColorMessage()); }); it('sends nothing when no rw attributes have been set', async () => { - const device = createDevice(); + createDevice(); - await Reflect.apply((device as any).syncState, device, []); + await triggerReconnect(); expect(mockHandler.send).not.toHaveBeenCalled(); }); @@ -289,4 +296,309 @@ describe('AiroticDevice', () => { expect(mockHandler.send).toHaveBeenNthCalledWith(2, AiroticProtocol.createSetColorMessage(0, 255, 0)); }); }); + + describe('constructor / transport wiring', () => { + it('registers an onReceive callback on the transport', () => { + const device = createDevice(); + + expect(mockTransport.onReceive).toHaveBeenCalledOnce(); + expect(mockTransport.onReceive).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('registers an onConnected callback on the transport', () => { + const device = createDevice(); + + expect(mockTransport.onConnected).toHaveBeenCalledOnce(); + expect(mockTransport.onConnected).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('onConnected callback triggers syncState', async () => { + let onConnectedCb: (() => void) | undefined; + mockTransport.onConnected.mockImplementation((cb) => { onConnectedCb = cb; }); + + const device = createDevice(); + + // Set something so reconnect shows it recovers state + await device.setAttribute('restColor', '10,20,30'); + mockHandler.send.mockClear(); + + // Simulate transport reconnect and flush the async chain syncState uses + onConnectedCb!(); + await vi.advanceTimersByTimeAsync(0); + + // syncState should replay the restColor + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); + expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSetColorMessage(10, 20, 30)); + }); + + it('syncState error is logged and not rethrown', async () => { + let onConnectedCb: (() => void) | undefined; + mockTransport.onConnected.mockImplementation((cb) => { onConnectedCb = cb; }); + + const device = createDevice(); + + // Give it a color so syncState actually sends something + await device.setAttribute('restColor', '1,2,3'); + mockHandler.send.mockRejectedValue(new Error('BLE write failed')); + + onConnectedCb!(); + await vi.advanceTimersByTimeAsync(0); + + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('onReceiveTransportData', () => { + let onReceiveCb: ((data: Buffer) => void) | undefined; + let device: AiroticDevice; + + beforeEach(() => { + mockTransport.onReceive.mockImplementation((cb) => { onReceiveCb = cb; }); + device = createDevice(); + }); + + it('emits deviceNotification with colorType breathInColor on *B', () => { + const listener = vi.fn(); + device.on(DeviceEvent.deviceNotification, listener); + + onReceiveCb!(Buffer.from('*B', 'utf-8')); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(device, { + type: 'colorChange', + data: { colorType: 'breathInColor' }, + }); + }); + + it('emits deviceNotification with colorType restColor on *R', () => { + const listener = vi.fn(); + device.on(DeviceEvent.deviceNotification, listener); + + onReceiveCb!(Buffer.from('*R', 'utf-8')); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(device, { + type: 'colorChange', + data: { colorType: 'restColor' }, + }); + }); + + it('does not emit deviceNotification for unknown data', () => { + const listener = vi.fn(); + device.on(DeviceEvent.deviceNotification, listener); + + onReceiveCb!(Buffer.from('UNKNOWN', 'utf-8')); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('*B triggers recordBreath but *R does not', () => { + const refreshListener = vi.fn(); + device.on(DeviceEvent.deviceRefreshed, refreshListener); + + // *R — no breath recorded, no refresh + onReceiveCb!(Buffer.from('*R', 'utf-8')); + expect(refreshListener).not.toHaveBeenCalled(); + + // *B — breath recorded → updateLastRefresh called + onReceiveCb!(Buffer.from('*B', 'utf-8')); + expect(refreshListener).toHaveBeenCalledOnce(); + }); + }); + + describe('breath recording and BPM (trend) calculation', () => { + let onReceiveCb: ((data: Buffer) => void) | undefined; + let device: AiroticDevice; + + const sendBreaths = (intervals: number[]): void => { + onReceiveCb!(Buffer.from('*B', 'utf-8')); + for (const gap of intervals) { + vi.advanceTimersByTime(gap); + onReceiveCb!(Buffer.from('*B', 'utf-8')); + } + }; + + beforeEach(() => { + mockTransport.onReceive.mockImplementation((cb) => { onReceiveCb = cb; }); + device = createDevice(); + }); + + it('breathsPerMin stays undefined with only one breath', () => { + onReceiveCb!(Buffer.from('*B', 'utf-8')); + + expect(device['attributes'].breathsPerMin.value).toBeUndefined(); + }); + + it('computes correct BPM from two breaths 6 seconds apart', () => { + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(6_000); + onReceiveCb!(Buffer.from('*B', 'utf-8')); + + // 1 interval of 6s → 60/6 = 10 breaths/min + expect(device['attributes'].breathsPerMin.value).toBe(10); + }); + + it('computes correct BPM from three breaths equally spaced', () => { + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(4_000); + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(4_000); + onReceiveCb!(Buffer.from('*B', 'utf-8')); + + // 2 intervals totalling 8s, 2 gaps → 60*2/8 = 15 breaths/min + expect(device['attributes'].breathsPerMin.value).toBe(15); + }); + + it('evicts timestamps older than the 60s window before calculating BPM', () => { + // Breath recorded 61s ago (outside window) + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(61_000); + + // Two breaths inside the window, 3s apart + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(3_000); + onReceiveCb!(Buffer.from('*B', 'utf-8')); + + // Only the two in-window breaths count: 1 interval of 3s → 20 bpm + expect(device['attributes'].breathsPerMin.value).toBe(20); + }); + + it('calls updateLastRefresh on each breath', () => { + const listener = vi.fn(); + device.on(DeviceEvent.deviceRefreshed, listener); + + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(1_000); + onReceiveCb!(Buffer.from('*B', 'utf-8')); + + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('resets breathsPerMin and bpmTrend to undefined after 20s without a breath', () => { + // Establish a BPM reading first + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(3_000); + onReceiveCb!(Buffer.from('*B', 'utf-8')); + expect(device['attributes'].breathsPerMin.value).toBeDefined(); + + // Let the timeout expire + vi.advanceTimersByTime(20_000); + + expect(device['attributes'].breathsPerMin.value).toBeUndefined(); + expect(device['attributes'].bpmTrend.value).toBeUndefined(); + }); + + it('clears timestamps after timeout fires', () => { + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(20_000); + + expect(device['breathTimestamps']).toHaveLength(0); + }); + + it('calls updateLastRefresh when timeout fires', () => { + onReceiveCb!(Buffer.from('*B', 'utf-8')); + + const listener = vi.fn(); + device.on(DeviceEvent.deviceRefreshed, listener); + + vi.advanceTimersByTime(20_000); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it('a second breath before timeout resets the timer', () => { + onReceiveCb!(Buffer.from('*B', 'utf-8')); + vi.advanceTimersByTime(15_000); + + // Second breath resets the 20s countdown + onReceiveCb!(Buffer.from('*B', 'utf-8')); + + // 15s later the original timer would have fired — data must still be present + vi.advanceTimersByTime(15_000); + expect(device['attributes'].breathsPerMin.value).toBeDefined(); + + // Now 20s after the second breath, the new timer fires + vi.advanceTimersByTime(5_000); + expect(device['attributes'].breathsPerMin.value).toBeUndefined(); + }); + + it('returns undefined when fewer than 7 timestamps are recorded', () => { + // 6 breaths = 5 intervals, need 7 timestamps (6 intervals) + sendBreaths([1000, 1000, 1000, 1000, 1000]); + expect(device['attributes'].bpmTrend.value).toBeUndefined(); + }); + + it('returns stable when recent intervals are within the 10% threshold', () => { + // 7 equal intervals → no change + sendBreaths([2000, 2000, 2000, 2000, 2000, 2000]); + expect(device['attributes'].bpmTrend.value).toBe('stable'); + }); + + it('returns up when recent intervals are more than 10% shorter (breathing faster)', () => { + // First 3 intervals slow (4000ms), last 3 fast (1000ms) + sendBreaths([4000, 4000, 4000, 1000, 1000, 1000]); + expect(device['attributes'].bpmTrend.value).toBe('up'); + }); + + it('returns down when recent intervals are more than 10% longer (breathing slower)', () => { + // First 3 intervals fast (1000ms), last 3 slow (4000ms) + sendBreaths([1000, 1000, 1000, 4000, 4000, 4000]); + expect(device['attributes'].bpmTrend.value).toBe('down'); + }); + }); + + describe('doClose', () => { + it('clears the breath timeout handle when one is active', async () => { + let onReceiveCb: ((data: Buffer) => void) | undefined; + mockTransport.onReceive.mockImplementation((cb) => { onReceiveCb = cb; }); + const device = createDevice(); + + // Start the timeout by recording a breath + onReceiveCb!(Buffer.from('*B', 'utf-8')); + expect(device['breathTimeoutHandle']).not.toBeNull(); + + await device.close(); + + expect(device['breathTimeoutHandle']).toBeNull(); + }); + + it('does not throw when breathTimeoutHandle is null on close', async () => { + const device = createDevice(); + + // No breaths recorded — handle stays null + expect(device['breathTimeoutHandle']).toBeNull(); + + await expect(device.close()).resolves.not.toThrow(); + }); + + it('calls transport.close() when device closes', async () => { + const device = createDevice(); + + await device.close(); + + expect(mockTransport.close).toHaveBeenCalledOnce(); + }); + + it('calls super.doClose() before transport.close()', async () => { + const callOrder: string[] = []; + mockPeripheral.disconnectAsync.mockImplementation(async () => { callOrder.push('peripheral'); }); + mockTransport.close.mockImplementation(async () => { callOrder.push('transport'); }); + + const device = createDevice(); + await device.close(); + + expect(callOrder).toStrictEqual(['peripheral', 'transport']); + }); + }); + + describe('setAttribute reboot', () => { + it('calls sleep(500) between sending the reboot message and closing', async () => { + const { sleep } = await import('../../../../../src/util/async.js'); + const device = createDevice(); + + await device.setAttribute('reboot', true); + + expect(sleep).toHaveBeenCalledWith(500); + }); + }); }); diff --git a/tests/unit/device/protocol/airotic/airoticProtocol.spec.ts b/tests/unit/device/protocol/airotic/airoticProtocol.spec.ts index ee629328..c2f050c7 100644 --- a/tests/unit/device/protocol/airotic/airoticProtocol.spec.ts +++ b/tests/unit/device/protocol/airotic/airoticProtocol.spec.ts @@ -17,12 +17,12 @@ describe('AiroticProtocol', () => { describe('decode', () => { it('decodes buffer to a utf-8 string message', () => { - const input = Buffer.from('Hello I am bottle v2.0', 'utf8'); + const input = Buffer.from('Hello I am bottle 7', 'utf8'); const result = protocol.decode(input); expectToBeSuccessfulDecodeResult(result); - expect(result.message).toStrictEqual('Hello I am bottle v2.0'); + expect(result.message).toStrictEqual('Hello I am bottle 7'); }); }); @@ -30,7 +30,7 @@ describe('AiroticProtocol', () => { it('matches the hello message when response starts with the hello prefix', () => { const msg = AiroticProtocol.createHelloMessage(); - expect(protocol.isResponseMatchingMessage('Hello I am bottle v2.0', msg)).toBe(true); + expect(protocol.isResponseMatchingMessage('Hello I am bottle 7', msg)).toBe(true); }); it('does not match the hello message when response has a different prefix', () => { @@ -42,7 +42,7 @@ describe('AiroticProtocol', () => { it('does not match when message is not the hello message', () => { const msg = AiroticProtocol.createSetColorMessage(255, 0, 0); - expect(protocol.isResponseMatchingMessage('Hello I am bottle v2.0', msg)).toBe(false); + expect(protocol.isResponseMatchingMessage('Hello I am bottle 7', msg)).toBe(false); }); }); diff --git a/tests/unit/device/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts index 1712978b..778ee574 100644 --- a/tests/unit/device/protocol/zc95/zc95Device.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -125,17 +125,6 @@ describe('Zc95Device', () => { ).rejects.toThrow("Attribute with name 'powerChannel1' does not exist for this device"); }); - it('throws an error when the value type is invalid for the attribute', async () => { - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(), - }); - - await expect( - device.setAttribute('patternStarted', 'not-a-bool') - ).rejects.toThrow(); - }); - describe('activePattern', () => { it('does not send any messages when the pattern is already active', async () => { const device = createDevice({ diff --git a/tests/unit/device/testDevice.ts b/tests/unit/device/testDevice.ts index 7f707383..eebed15f 100644 --- a/tests/unit/device/testDevice.ts +++ b/tests/unit/device/testDevice.ts @@ -15,8 +15,9 @@ export default class TestDevice extends Device } public async setAttribute< - K extends AttributeKeyOf - >(attributeName: K, value: AttributeValueOf): Promise> { + K extends AttributeKeyOf, + V extends AttributeValueOf + >(_attributeName: K, _value: V): Promise { throw new Error("Method not implemented."); } } diff --git a/tsconfig.json b/tsconfig.json index ed6dffe3..cee57dc6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,9 @@ "paths": { "@serialport/bindings-interface": [ "./node_modules/@serialport/bindings-interface/dist/index.d.ts" + ], + "type-detect": [ + "./node_modules/@types/type-detect/index.d.ts" ] } }, diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index 77f2d824..e044e72f 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -7,6 +7,5 @@ export default defineProject({ setupFiles: ["./tests/vitest.setup.ts"], include: ["tests/integration/**/*.spec.ts"], testTimeout: 10000, - hookTimeout: 10000, }, }); From 0a7632307dc61d26b836a02356a3496177a67fe3 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 4 Jul 2026 12:54:39 +0200 Subject: [PATCH 39/42] Remove this syncState workaround --- src/device/attribute/strDeviceAttribute.ts | 3 +- src/device/bleDevice.ts | 2 +- src/device/protocol/airotic/airoticDevice.ts | 32 ++---- .../protocol/airotic/airoticDeviceProvider.ts | 31 +++++- src/util/color.ts | 36 +++++++ .../protocol/airotic/airoticDevice.spec.ts | 97 +------------------ 6 files changed, 76 insertions(+), 125 deletions(-) create mode 100644 src/util/color.ts diff --git a/src/device/attribute/strDeviceAttribute.ts b/src/device/attribute/strDeviceAttribute.ts index 41e7853e..a3daac90 100644 --- a/src/device/attribute/strDeviceAttribute.ts +++ b/src/device/attribute/strDeviceAttribute.ts @@ -18,8 +18,9 @@ export default class StrDeviceAttribute750ms) while updating RSSI for device ${this.deviceId}`); - this.logger.debug(`Received RSSI update for device ${this.deviceId}: ${rssi}`); + this.logger.trace(`Received RSSI update for device ${this.deviceId}: ${rssi}`); this.rssi = rssi; this.updateLastRefresh(); diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 88ba4db7..94e03b60 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -13,7 +13,6 @@ import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; import { sleep } from '../../../util/async.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; -import { logError } from '../../../util/error.js'; import { Float } from '../../../util/numbers.js'; import typeDetect from 'type-detect'; @@ -21,6 +20,8 @@ const BREATH_WINDOW_MS = 60_000; const BREATH_TIMEOUT_MS = 20_000; const BPM_TREND_INTERVALS = 6; const BPM_TREND_THRESHOLD = 0.10; +const DEFAULT_REST_COLOR = '0,0,255'; +const DEFAULT_BREATH_IN_COLOR = '255,0,128'; export type BpmTrend = 'up' | 'down' | 'stable'; @@ -69,12 +70,6 @@ export default class AiroticDevice extends BleDevice { - this.syncState().catch( - (e: unknown) => logError(this.logger, `Error syncing state after reconnect for device ${this.deviceId}`, e) - ); - }); - this.transport.onReceive(data => this.onReceiveTransportData(data)); } @@ -86,7 +81,7 @@ export default class AiroticDevice extends BleDevice { - const { restColor, breathInColor } = this.attributes; - - if (undefined !== restColor.value) { - const { r, g, b } = this.parseColor(restColor.value); - await this.messageResponseHandler.send(AiroticProtocol.createSelectRestColorMessage()); - await sleep(100); - await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); - } - - if (undefined !== breathInColor.value) { - const { r, g, b } = this.parseColor(breathInColor.value); - await this.messageResponseHandler.send(AiroticProtocol.createSelectBreathInColorMessage()); - await sleep(100); - await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); - } - } - public async setAttribute< K extends AttributeKeyOf, V extends AttributeValueOf @@ -219,6 +196,9 @@ export default class AiroticDevice extends BleDevice { @@ -60,6 +61,8 @@ export default class AiroticDeviceProvider extends BleDeviceProvider): Promise { try { const answer = await messageResponseHandler.send(AiroticProtocol.createHelloMessage(), 500); diff --git a/src/util/color.ts b/src/util/color.ts new file mode 100644 index 00000000..b70e2f11 --- /dev/null +++ b/src/util/color.ts @@ -0,0 +1,36 @@ +/** + * Converts a byte-scaled HSV color (h, s, v each 0-255) into an RGB color (each 0-255). + */ +export const hsvByteToRgb = (h: number, s: number, v: number): { r: number, g: number, b: number } => { + const hue = (h / 255) * 360; + const saturation = s / 255; + const value = v / 255; + + const c = value * saturation; + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); + const m = value - c; + + let rPrime: number; + let gPrime: number; + let bPrime: number; + + if (hue < 60) { + [rPrime, gPrime, bPrime] = [c, x, 0]; + } else if (hue < 120) { + [rPrime, gPrime, bPrime] = [x, c, 0]; + } else if (hue < 180) { + [rPrime, gPrime, bPrime] = [0, c, x]; + } else if (hue < 240) { + [rPrime, gPrime, bPrime] = [0, x, c]; + } else if (hue < 300) { + [rPrime, gPrime, bPrime] = [x, 0, c]; + } else { + [rPrime, gPrime, bPrime] = [c, 0, x]; + } + + return { + r: Math.round((rPrime + m) * 255), + g: Math.round((gPrime + m) * 255), + b: Math.round((bPrime + m) * 255), + }; +}; diff --git a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts index 24d47a01..043bd704 100644 --- a/tests/unit/device/protocol/airotic/airoticDevice.spec.ts +++ b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts @@ -93,7 +93,7 @@ describe('AiroticDevice', () => { expect(result).toStrictEqual('100,200,50'); }); - it('persists the value so syncState can replay it', async () => { + it('persists the value on the attribute', async () => { const device = createDevice(); await device.setAttribute('restColor', '10,20,30'); @@ -121,7 +121,7 @@ describe('AiroticDevice', () => { expect(result).toStrictEqual('50,50,50'); }); - it('persists the value so syncState can replay it', async () => { + it('persists the value on the attribute', async () => { const device = createDevice(); await device.setAttribute('breathInColor', '5,10,15'); @@ -193,58 +193,6 @@ describe('AiroticDevice', () => { }); }); - describe('syncState', () => { - async function triggerReconnect(): Promise { - const onConnectedCallback = mockTransport.onConnected.mock.calls[0][0]; - onConnectedCallback(); - await vi.advanceTimersByTimeAsync(0); - } - - it('re-sends restColor and breathInColor when both have been set', async () => { - const device = createDevice(); - await device.setAttribute('restColor', '255,0,0'); - await device.setAttribute('breathInColor', '0,0,255'); - mockHandler.send.mockClear(); - - await triggerReconnect(); - - expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); - expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSetColorMessage(255, 0, 0)); - expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectBreathInColorMessage()); - expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSetColorMessage(0, 0, 255)); - }); - - it('skips restColor when it has not been set', async () => { - const device = createDevice(); - await device.setAttribute('breathInColor', '0,0,255'); - mockHandler.send.mockClear(); - - await triggerReconnect(); - - expect(mockHandler.send).not.toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); - expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectBreathInColorMessage()); - }); - - it('skips breathInColor when it has not been set', async () => { - const device = createDevice(); - await device.setAttribute('restColor', '255,0,0'); - mockHandler.send.mockClear(); - - await triggerReconnect(); - - expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); - expect(mockHandler.send).not.toHaveBeenCalledWith(AiroticProtocol.createSelectBreathInColorMessage()); - }); - - it('sends nothing when no rw attributes have been set', async () => { - createDevice(); - - await triggerReconnect(); - - expect(mockHandler.send).not.toHaveBeenCalled(); - }); - }); - describe('color parsing', () => { it('throws when color has wrong number of components', async () => { const device = createDevice(); @@ -305,47 +253,6 @@ describe('AiroticDevice', () => { expect(mockTransport.onReceive).toHaveBeenCalledWith(expect.any(Function)); }); - it('registers an onConnected callback on the transport', () => { - const device = createDevice(); - - expect(mockTransport.onConnected).toHaveBeenCalledOnce(); - expect(mockTransport.onConnected).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('onConnected callback triggers syncState', async () => { - let onConnectedCb: (() => void) | undefined; - mockTransport.onConnected.mockImplementation((cb) => { onConnectedCb = cb; }); - - const device = createDevice(); - - // Set something so reconnect shows it recovers state - await device.setAttribute('restColor', '10,20,30'); - mockHandler.send.mockClear(); - - // Simulate transport reconnect and flush the async chain syncState uses - onConnectedCb!(); - await vi.advanceTimersByTimeAsync(0); - - // syncState should replay the restColor - expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSelectRestColorMessage()); - expect(mockHandler.send).toHaveBeenCalledWith(AiroticProtocol.createSetColorMessage(10, 20, 30)); - }); - - it('syncState error is logged and not rethrown', async () => { - let onConnectedCb: (() => void) | undefined; - mockTransport.onConnected.mockImplementation((cb) => { onConnectedCb = cb; }); - - const device = createDevice(); - - // Give it a color so syncState actually sends something - await device.setAttribute('restColor', '1,2,3'); - mockHandler.send.mockRejectedValue(new Error('BLE write failed')); - - onConnectedCb!(); - await vi.advanceTimersByTimeAsync(0); - - expect(mockLogger.error).toHaveBeenCalled(); - }); }); describe('onReceiveTransportData', () => { From 520697d00927e074cf5a827fe97e620476c099f9 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 4 Jul 2026 15:58:48 +0200 Subject: [PATCH 40/42] fix reconnect issue --- src/automation/scriptRuntime.ts | 46 ++++++++++++++++++--- src/device/deviceManager.ts | 2 +- src/device/transport/bleDeviceTransport.ts | 14 +++++++ tests/unit/automation/scriptRuntime.spec.ts | 25 +++++++++++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index ff738f05..8308a0c7 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -46,13 +46,47 @@ type ScriptRuntimeEvents = { * - devices.getAll() – Device[] */ const BOOTSTRAP_SCRIPT = ` +function __formatLogArg(arg) { + if (typeof arg === 'string') { + return arg; + } + + if (typeof arg === 'function') { + return arg.toString(); + } + + if (arg === null || typeof arg !== 'object') { + return String(arg); + } + + try { + const seen = new WeakSet(); + return JSON.stringify(arg, (_key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + + return typeof value === 'bigint' ? value.toString() : value; + }, 2); + } catch (e) { + return String(arg); + } +} + +function __formatLogArgs(args) { + return args.map(__formatLogArg).join(' '); +} + var console = { - log: (...args) => __log.applySync(undefined, ['log', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), - error: (...args) => __log.applySync(undefined, ['error', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), - warn: (...args) => __log.applySync(undefined, ['warn', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), - info: (...args) => __log.applySync(undefined, ['info', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), - debug: (...args) => __log.applySync(undefined, ['debug', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), - trace: (...args) => __log.applySync(undefined, ['trace', args.map(a => String(a)).join(' ')], { arguments: { copy: true } }), + log: (...args) => __log.applySync(undefined, ['log', __formatLogArgs(args)], { arguments: { copy: true } }), + error: (...args) => __log.applySync(undefined, ['error', __formatLogArgs(args)], { arguments: { copy: true } }), + warn: (...args) => __log.applySync(undefined, ['warn', __formatLogArgs(args)], { arguments: { copy: true } }), + info: (...args) => __log.applySync(undefined, ['info', __formatLogArgs(args)], { arguments: { copy: true } }), + debug: (...args) => __log.applySync(undefined, ['debug', __formatLogArgs(args)], { arguments: { copy: true } }), + trace: (...args) => __log.applySync(undefined, ['trace', __formatLogArgs(args)], { arguments: { copy: true } }), }; async function __resolveAttr(deviceId, attributeName) { diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 4b935efe..d6136670 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -50,7 +50,7 @@ export default class DeviceManager public announceDetectedDevice(deviceInfo: DeviceInfo): void { - if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)/* || this.connectedDevices.has(deviceInfo.id)*/) { + if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)) { return; } diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index 378c66c6..042c8923 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -49,9 +49,11 @@ export default class BleUartDeviceTransport implements DeviceBidirectionalTransp } this.isSubscribing = true; + let weSelfConnected = false; try { if (this.peripheral.state === 'disconnected') { + weSelfConnected = true; await this.peripheral.connectAsync(); } else if (this.peripheral.state !== 'connected') { throw new Error(`Peripheral in unexpected state: ${this.peripheral.state}`); @@ -88,6 +90,18 @@ export default class BleUartDeviceTransport implements DeviceBidirectionalTransp for (const callback of this.onConnectedSubscribers) { callback(); } + } catch (e) { + // If we didn't initiate connection ourselves but subscribe() still failed, + // force disconnect so BleDevice.reconnectHandler can restart cycle via 'disconnect' event. + if (!weSelfConnected && this.peripheral.state === 'connected') { + try { + await this.peripheral.disconnectAsync(); + } catch { + // ignore — reconnect handler will pick this up via the 'disconnect' event + } + } + + throw e; } finally { this.isSubscribing = false; } diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index 3e2aaa62..43045fe2 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -180,6 +180,31 @@ describe('ScriptRuntime (isolated-vm)', () => { expect(logs).toContain('hello world'); }); + it('console.log formats object arguments as JSON instead of [object Object]', async () => { + await runtime.load(` + onEvent('deviceConnected', async (device) => { + console.log({ foo: 'bar', nested: { baz: 42 } }); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs[0]).not.toContain('[object Object]'); + expect(JSON.parse(logs[0])).toStrictEqual({ foo: 'bar', nested: { baz: 42 } }); + }); + + it('console.log formats array and mixed arguments readably', async () => { + await runtime.load(` + onEvent('deviceConnected', async (device) => { + console.log('values:', [1, 2, 3], true, undefined, null); + console.log('${TEST_END_MARKER}'); + }); + `); + + const logs = await dispatchAndCollect(eventEmitter, runtime, deviceA, TEST_END_MARKER); + expect(logs[0]).toBe('values: [\n 1,\n 2,\n 3\n] true undefined null'); + }); + // ----------------------------------------------------------------------- // event name filtering // ----------------------------------------------------------------------- From c5b684e84cd094c961f9819e69db3324194da18d Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 4 Jul 2026 16:44:38 +0200 Subject: [PATCH 41/42] Review findings --- src/automation/scriptRuntime.ts | 35 +++++++++---------- src/device/deviceManager.ts | 15 +++++++- .../protocol/virtual/virtualDeviceProvider.ts | 19 ++++++++++ src/device/provider/bleDeviceProvider.ts | 2 ++ 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 8308a0c7..0fb3b904 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -9,7 +9,6 @@ import AutomationEventType from './automationEventType.js'; import { DeviceManagerEvent } from '../device/deviceManager.js'; import { AttributeValue } from '../device/attribute/deviceAttribute.js'; import Logger from '../logging/Logger.js'; -import { logError } from '../util/error.js'; export type SupportedDeviceEvent = | { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: Device; args: [] } @@ -33,17 +32,12 @@ type ScriptRuntimeEvents = { * - __lifecycleDone Callback – signals onStart/onStop completion (null = ok, string = error) * * Script-facing API: - * - console.log(...) - * - onStart(handler) – run once when script loads: () => void | Promise - * - onStop(handler) – run once when script stops: () => void | Promise - * - onEvent(eventName, handler) – register handler for a specific event: (device, ...args) => void - * - event.type – string – current event type - * - event.device.getDeviceId – string - * - event.device.getDeviceName – string - * - event.device.getAttribute(name) – Promise<{ value, name, label, modifier, type } | undefined> - * - event.device.setAttribute(name, v) – Promise - * - devices.getById(id) – Device | null - * - devices.getAll() – Device[] + * - console.log(...args) – log to host console and automation.log + * - onStart(async () => void) – run once when script loads: () => void | Promise + * - onStop(async () => void) – run once when script stops: () => void | Promise + * - onEvent(eventName, async (device, ...args) => void) – register handler for a specific event: (device, ...args) => void + * - devices.getById(id) – Device | null + * - devices.getAll() – Device[] */ const BOOTSTRAP_SCRIPT = ` function __formatLogArg(arg) { @@ -107,9 +101,12 @@ function __createDeviceProxy(deviceJson) { const attr = await __resolveAttr(d.id, attributeName); return attr ?? undefined; }, - setAttribute(attributeName, value) { - __setAttribute.applySync(undefined, [d.id, attributeName, value], { arguments: { copy: true } }); - return Promise.resolve(); + async setAttribute(attributeName, value) { + await __setAttribute.apply( + undefined, + [d.id, attributeName, value], + { arguments: { copy: true }, result: { promise: true } } + ); } }); } @@ -257,10 +254,10 @@ export default class ScriptRuntime return JSON.stringify({ id: dev.getDeviceId, name: dev.getDeviceName }); })); - await jail.set('__setAttribute', new ivm.Reference((deviceId: string, attrName: string, value: AttributeValue): void => { + await jail.set('__setAttribute', new ivm.Reference(async (deviceId: string, attrName: string, value: AttributeValue): Promise => { const dev = this.deviceRepository.getById(deviceId); - if (dev === null) return; - dev.setAttribute(attrName, value).catch((e: unknown) => logError(this.logger, 'VM setAttribute failed', e)); + if (dev === null) throw new Error(`Device not found: ${deviceId}`); + await dev.setAttribute(attrName, value); })); await jail.set('__getDevicesJson', new ivm.Reference((): string => { @@ -431,7 +428,7 @@ export default class ScriptRuntime const msg = e instanceof Error ? e.message : String(e); this.logger.error(`VM error: ${msg}`); this.log(msg); - this.eventEmitter.emit(AutomationEventType.consoleLog, String(e)); + this.eventEmitter.emit(AutomationEventType.consoleLog, msg); } } diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index d6136670..44082578 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -166,13 +166,26 @@ export default class DeviceManager public async reset(): Promise { + let closeError: unknown; + for (const [, device] of this.connectedDevices) { - await device.close(); + try { + await device.close(); + } catch (e: unknown) { + logError(this.logger, `device: ${device.getDeviceId} -> close during reset -> failed`, e); + if (undefined === closeError) { + closeError = e; + } + } } for (const [deviceId] of this.detectedDeviceAcquireQueue) { this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); } + + if (undefined !== closeError) { + throw closeError; + } } private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index f90e09ee..fbcaa97a 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -25,6 +25,8 @@ export default class VirtualDeviceProvider extends DeviceProvider private discoveryInterval?: NodeJS.Timeout; + private stopped: boolean = false; + public constructor( deviceManager: DeviceManager, eventEmitter: EventEmitter, @@ -40,6 +42,8 @@ export default class VirtualDeviceProvider extends DeviceProvider } public override async init(): Promise { + this.stopped = false; + this.discoveryInterval ??= setImmediateInterval(asyncHandler( this.discoverVirtualDevices.bind(this), (e: unknown) => this.logger.error('Error while scanning for new virtual devices', e) @@ -47,6 +51,8 @@ export default class VirtualDeviceProvider extends DeviceProvider } public override async stop(): Promise { + this.stopped = true; + if (this.discoveryInterval !== undefined) { clearInterval(this.discoveryInterval); this.discoveryInterval = undefined; @@ -58,6 +64,10 @@ export default class VirtualDeviceProvider extends DeviceProvider } private async discoverVirtualDevices(): Promise { + if (this.stopped) { + return; + } + const settings = this.settingsManager.getSettings(); if (undefined === settings) { @@ -76,6 +86,10 @@ export default class VirtualDeviceProvider extends DeviceProvider // Load all currently configured devices for (const [k, v] of virtualDevices) { + if (this.stopped) { + return; + } + if (this.attemptedDevices.has(k) || this.connectedDevices.has(k)) { continue; } @@ -92,6 +106,11 @@ export default class VirtualDeviceProvider extends DeviceProvider try { const device = await this.deviceFactory.create(knowDevice, VirtualDeviceProvider.providerName); + if (this.stopped) { + await device.close(); + this.attemptedDevices.delete(knowDevice.id); + return; + } this.deviceManager.addDevice(device); this.connectedDevices.set(knowDevice.id, device); diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index 92c9bbee..b7b36009 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -85,6 +85,8 @@ export default abstract class BleDeviceProvider< } catch (e: unknown) { logError(this.logger, `Error disconnecting peripheral ${peripheral.id}`, e); } + } else if (peripheral.state === 'connecting') { + peripheral.cancelConnect(); } } From ec6e4aa3d183251b45589d864c1aeb481867a759 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 4 Jul 2026 17:51:31 +0200 Subject: [PATCH 42/42] Fix last review comment --- src/automation/scriptRuntime.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 0fb3b904..5e4a0d7b 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -53,6 +53,10 @@ function __formatLogArg(arg) { return String(arg); } + if (arg instanceof Error) { + return arg.stack ?? \`\${arg.name}: \${arg.message}\`; + } + try { const seen = new WeakSet(); return JSON.stringify(arg, (_key, value) => {