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/eslint.config.ts b/eslint.config.ts index fe61030f..46789984 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'; @@ -12,12 +12,13 @@ export default [ 'coverage', 'tests', 'vitest.config.ts', + 'vitest.config.unit.ts', 'vitest.config.integration.ts', 'eslint.config.ts', ], }, - jsLint.configs.recommended, - ...tsLint.configs.recommended, + eslint.configs.recommended, + ...tseslint.configs.recommended, preferArrowFunctions.configs?.all ?? [], { languageOptions: { @@ -57,7 +58,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": [ @@ -84,6 +91,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 8ffbbe60..a7fede70 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.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", @@ -19,7 +20,7 @@ "modern-errors": "^7.1.4", "node-os-utils": "^2.0.3", "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", @@ -39,25 +40,27 @@ "@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": "^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", + "pino-pretty": "^13.1.3", "socket.io-client": "^4.8.3", "supertest": "^7.2.2", "typescript": "^5.9.3", - "typescript-eslint": "^8.52.0", + "typescript-eslint": "^8.57.1", "vitest": "^4.0.16", "vitest-mock-extended": "^3.1.0", "ws": "^8.21.0" @@ -82,7 +85,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", + "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": { @@ -90,19 +95,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", + "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", @@ -136,23 +143,27 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", + "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", + "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" @@ -162,16 +173,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", + "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": { @@ -182,11 +195,13 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", + "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" }, @@ -198,15 +213,17 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", + "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" @@ -223,12 +240,14 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", + "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" @@ -267,11 +286,13 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", + "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" @@ -288,13 +309,15 @@ } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", + "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" @@ -304,13 +327,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", + "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" @@ -320,12 +345,14 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", + "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" @@ -352,7 +379,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", + "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": { @@ -360,38 +389,44 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", + "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", + "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", + "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" @@ -423,12 +458,14 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", + "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" @@ -438,11 +475,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", + "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" @@ -452,11 +491,30 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", + "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" @@ -466,13 +524,15 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", + "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" @@ -482,12 +542,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", + "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" @@ -497,13 +559,15 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.28.6", + "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" @@ -524,11 +588,13 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.28.6", + "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" @@ -538,11 +604,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", + "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" @@ -552,11 +620,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", + "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" @@ -566,11 +636,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", + "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" @@ -580,11 +652,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", + "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" @@ -609,11 +683,13 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", + "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" @@ -623,13 +699,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.6", + "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" @@ -639,13 +717,15 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", + "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" @@ -655,11 +735,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", + "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" @@ -669,11 +751,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", + "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" @@ -683,12 +767,14 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", + "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" @@ -698,12 +784,14 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", + "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" @@ -713,16 +801,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", + "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" @@ -732,12 +822,14 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", + "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" @@ -747,12 +839,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", + "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" @@ -762,12 +856,14 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", + "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" @@ -777,11 +873,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", + "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" @@ -791,12 +889,14 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.28.6", + "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" @@ -806,11 +906,13 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", + "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" @@ -820,12 +922,14 @@ } }, "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", + "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" @@ -835,11 +939,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", + "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" @@ -849,11 +955,13 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", + "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" @@ -863,12 +971,14 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", + "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" @@ -878,13 +988,15 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", + "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" @@ -894,11 +1006,13 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", + "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" @@ -908,11 +1022,13 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", + "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" @@ -922,11 +1038,13 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", + "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" @@ -936,11 +1054,13 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", + "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" @@ -950,12 +1070,14 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", + "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" @@ -965,12 +1087,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", + "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" @@ -999,12 +1123,14 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", + "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" @@ -1014,12 +1140,14 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", + "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" @@ -1029,11 +1157,13 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", + "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" @@ -1043,11 +1173,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", + "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" @@ -1057,11 +1189,13 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", + "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" @@ -1071,15 +1205,17 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", + "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" @@ -1089,12 +1225,14 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", + "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" @@ -1104,11 +1242,13 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", + "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" @@ -1118,12 +1258,14 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", + "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" @@ -1133,11 +1275,13 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", + "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" @@ -1147,12 +1291,14 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", + "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" @@ -1162,13 +1308,15 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", + "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" @@ -1178,11 +1326,13 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", + "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" @@ -1192,11 +1342,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.6", + "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" @@ -1206,12 +1358,14 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", + "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" @@ -1221,11 +1375,13 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", + "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" @@ -1235,11 +1391,13 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", + "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" @@ -1249,12 +1407,14 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", + "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" @@ -1264,11 +1424,13 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", + "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" @@ -1278,11 +1440,13 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", + "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" @@ -1292,11 +1456,13 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", + "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" @@ -1306,15 +1472,17 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", + "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" @@ -1324,11 +1492,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", + "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" @@ -1338,12 +1508,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", + "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" @@ -1353,12 +1525,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", + "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" @@ -1368,12 +1542,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", + "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" @@ -1383,79 +1559,82 @@ } }, "node_modules/@babel/preset-env": { - "version": "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.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", + "@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": { @@ -1479,15 +1658,17 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", + "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" @@ -1497,7 +1678,9 @@ } }, "node_modules/@babel/register": { - "version": "7.28.6", + "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": { @@ -1571,21 +1754,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "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.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "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, @@ -1594,9 +1777,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "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, @@ -1605,18 +1788,20 @@ } }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.78.0", + "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.46.4", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~7.0.0" + "@typescript-eslint/types": "^8.58.0", + "comment-parser": "1.4.6", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.2.0" }, "engines": { - "node": ">=20.11.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@es-joy/resolve.exports": { @@ -1664,146 +1849,129 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", + "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": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "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", + "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": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", + "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": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "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" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.39.2", + "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", + "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": "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", + "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": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.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": { - "version": "0.19.1", + "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": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", + "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": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "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": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "dev": true, @@ -1830,6 +1998,8 @@ }, "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", @@ -1847,6 +2017,8 @@ }, "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": ">=6.0.0" @@ -1854,10 +2026,14 @@ }, "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", @@ -1865,14 +2041,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "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, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1951,6 +2127,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1967,6 +2146,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1983,6 +2165,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1999,6 +2184,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2057,9 +2245,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "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", "funding": { @@ -2081,9 +2269,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "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" ], @@ -2098,9 +2286,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "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" ], @@ -2115,9 +2303,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "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" ], @@ -2132,9 +2320,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "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" ], @@ -2149,9 +2337,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "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" ], @@ -2166,13 +2354,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "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, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2183,13 +2374,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "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": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2200,13 +2394,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "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, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2217,13 +2414,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "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": [ @@ -2234,13 +2434,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "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": [ @@ -2251,13 +2454,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "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": [ @@ -2268,9 +2474,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "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" ], @@ -2285,9 +2491,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "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": [ "wasm32" ], @@ -2295,18 +2501,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@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/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "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": [ "arm64" ], @@ -2321,9 +2527,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "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" ], @@ -2411,6 +2617,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", "license": "MIT", @@ -2548,7 +2763,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.47", + "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": { @@ -2573,6 +2790,381 @@ "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/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/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.5.5", + "resolved": "https://registry.npmjs.org/@stoprocent/noble/-/noble-2.5.5.tgz", + "integrity": "sha512-tHtc8nEvf+J11MedleT0YBhxBrWTqJaoXQJ9H983Gnrus1cHHxs2YtCHMk/3mFuoUAIvW0o1JcH8cHBieqkUYQ==", + "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" + }, + "peerDependencies": { + "dbus-next": "^0.10.0" + }, + "peerDependenciesMeta": { + "dbus-next": { + "optional": true + } + } + }, "node_modules/@timesplinter/pimple": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@timesplinter/pimple/-/pimple-2.1.1.tgz", @@ -2580,9 +3172,9 @@ "license": "LGPL-3.0-or-later" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "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, @@ -2639,8 +3231,17 @@ "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", + "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" }, @@ -2683,14 +3284,18 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.7", + "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/qs": { - "version": "6.14.0", + "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" }, @@ -2740,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", @@ -2748,24 +3360,28 @@ }, "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==", "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.53.0", + "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.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.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" @@ -2775,9 +3391,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.53.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.62.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -2789,14 +3405,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.53.0", + "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.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.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": { @@ -2807,17 +3425,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.53.0", + "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.53.0", - "@typescript-eslint/types": "^8.53.0", + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "engines": { @@ -2828,16 +3448,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.53.0", + "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.53.0", - "@typescript-eslint/visitor-keys": "8.53.0" + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2848,7 +3470,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.53.0", + "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": { @@ -2859,19 +3483,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.53.0", + "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.53.0", - "@typescript-eslint/typescript-estree": "8.53.0", - "@typescript-eslint/utils": "8.53.0", + "@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" @@ -2881,12 +3507,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.53.0", + "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": { @@ -2898,19 +3526,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.53.0", + "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.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.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": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2920,37 +3550,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", + "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": { @@ -2961,14 +3567,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.53.0", + "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.53.0", - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0" + "@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" @@ -2978,17 +3586,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.53.0", + "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.53.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.62.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2999,14 +3609,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "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.1.8", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -3020,8 +3630,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3030,16 +3640,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "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.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3048,13 +3658,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "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.1.8", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3075,9 +3685,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "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": { @@ -3088,13 +3698,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "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.1.8", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -3102,14 +3712,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "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.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3118,9 +3728,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "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": { @@ -3128,13 +3738,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "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.1.8", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3142,16 +3752,11 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } + "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/accepts": { "version": "2.0.0", @@ -3165,7 +3770,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -3216,7 +3823,8 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3252,11 +3860,6 @@ "node": ">=14" } }, - "node_modules/argparse": { - "version": "2.0.1", - "dev": true, - "license": "Python-2.0" - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "dev": true, @@ -3331,9 +3934,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", - "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "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": { @@ -3349,6 +3952,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", "dev": true, @@ -3386,12 +3996,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", + "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": { @@ -3399,51 +4011,41 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", + "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", + "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", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "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" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64id": { "version": "2.0.0", @@ -3453,11 +4055,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", + "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": { @@ -3472,18 +4079,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", + "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" @@ -3493,20 +4102,36 @@ "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "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": { "version": "3.0.3", - "dev": true, + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3516,7 +4141,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "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": [ { @@ -3534,11 +4161,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" @@ -3547,29 +4174,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "6.0.3", - "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", "license": "MIT", @@ -3613,13 +4217,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "dev": true, + "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": { @@ -3654,16 +4259,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { - "version": "1.0.30001764", + "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": [ { @@ -3693,7 +4292,8 @@ }, "node_modules/chalk": { "version": "4.1.2", - "dev": true, + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -3740,6 +4340,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", "license": "MIT" @@ -3759,7 +4374,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3770,7 +4386,8 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/colorette": { @@ -3800,7 +4417,9 @@ } }, "node_modules/comment-parser": { - "version": "1.4.1", + "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": { @@ -3822,13 +4441,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/content-disposition": { - "version": "1.0.1", + "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" @@ -3872,7 +4488,9 @@ "license": "MIT" }, "node_modules/core-js": { - "version": "3.47.0", + "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", @@ -3882,11 +4500,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.47.0", + "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", @@ -3894,7 +4514,9 @@ } }, "node_modules/cors": { - "version": "2.8.5", + "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", @@ -3902,11 +4524,16 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3995,7 +4622,8 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "dev": true, + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -4064,7 +4692,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.3", + "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" @@ -4090,7 +4720,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.267", + "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" }, @@ -4110,9 +4742,9 @@ } }, "node_modules/engine.io": { - "version": "6.6.8", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", - "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "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", @@ -4124,7 +4756,7 @@ "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.20.1" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" @@ -4186,27 +4818,6 @@ "node": ">= 0.6" } }, - "node_modules/engine.io/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "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", "license": "MIT", @@ -4225,7 +4836,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", + "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": { @@ -4291,6 +4904,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", "dev": true, @@ -4318,7 +4950,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", + "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" @@ -4342,13 +4976,18 @@ } }, "node_modules/es-to-primitive": { - "version": "1.3.0", + "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" @@ -4381,31 +5020,33 @@ } }, "node_modules/eslint": { - "version": "9.39.2", + "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.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.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", "@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", @@ -4415,8 +5056,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" }, @@ -4424,7 +5064,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" @@ -4453,61 +5093,38 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "61.7.1", + "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.78.0", + "@es-joy/jsdoccomment": "~0.86.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", + "comment-parser": "1.4.6", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", - "espree": "^11.0.0", + "espree": "^11.2.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", - "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", - "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", + "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": { @@ -4533,26 +5150,32 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", + "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", + "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" @@ -4581,16 +5204,18 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.4.0", + "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" @@ -4651,28 +5276,16 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventemitter3": { - "version": "5.0.1", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/expect-type": { - "version": "1.3.0", + "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": { @@ -4721,7 +5334,9 @@ } }, "node_modules/fast-copy": { - "version": "3.0.2", + "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" }, @@ -4773,7 +5388,8 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "dev": true, + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4839,6 +5455,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", "dev": true, @@ -4873,17 +5498,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "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.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4944,6 +5569,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/fsevents": { "version": "2.3.3", "dev": true, @@ -4964,16 +5603,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", + "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" @@ -5067,7 +5711,9 @@ } }, "node_modules/globals": { - "version": "17.0.0", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -5102,6 +5748,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", "dev": true, @@ -5115,7 +5767,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "dev": true, + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" @@ -5123,7 +5776,8 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "dev": true, + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -5171,7 +5825,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", + "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" @@ -5248,25 +5904,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "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", "dev": true, @@ -5280,21 +5917,6 @@ "dev": true, "license": "ISC" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "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", "dev": true, @@ -5413,11 +6035,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", + "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" @@ -5457,6 +6081,37 @@ "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-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", "license": "MIT", @@ -5539,7 +6194,8 @@ }, "node_modules/is-number": { "version": "7.0.0", - "dev": true, + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5712,14 +6368,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", - "dev": true, + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/isobject": { @@ -5732,6 +6402,8 @@ }, "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": { @@ -5777,7 +6449,9 @@ } }, "node_modules/istanbul-lib-report/node_modules/semver": { - "version": "7.7.3", + "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": { @@ -5800,7 +6474,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", + "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": { @@ -5820,19 +6496,10 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.1.1", - "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", + "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": { @@ -5859,6 +6526,25 @@ "version": "1.0.0", "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", "dev": true, @@ -5875,6 +6561,27 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "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" + }, + "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", "dev": true, @@ -5891,6 +6598,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", "dev": true, @@ -6046,6 +6762,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6067,6 +6786,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6088,6 +6810,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6109,6 +6834,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6166,6 +6894,8 @@ }, "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": { @@ -6187,11 +6917,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -6289,6 +7014,19 @@ "node": ">= 0.6" } }, + "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": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -6324,21 +7062,25 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "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": { "version": "1.2.8", - "dev": true, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6377,9 +7119,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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": [ { @@ -6408,7 +7150,9 @@ } }, "node_modules/node-addon-api": { - "version": "8.3.0", + "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" @@ -6442,25 +7186,34 @@ }, "node_modules/node-os-utils": { "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", + "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", + "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", @@ -6488,7 +7241,9 @@ } }, "node_modules/nodemon/node_modules/semver": { - "version": "7.7.3", + "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": { @@ -6536,7 +7291,9 @@ } }, "node_modules/object-deep-merge": { - "version": "2.0.0", + "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" }, @@ -6552,7 +7309,8 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "dev": true, + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6598,13 +7356,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", + "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", @@ -6644,6 +7407,22 @@ "version": "0.0.4", "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", "dev": true, @@ -6712,17 +7491,6 @@ "node": ">=6" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse-imports-exports": { "version": "0.2.4", "dev": true, @@ -6751,6 +7519,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.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" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -6761,7 +7570,8 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" @@ -6798,7 +7608,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6816,7 +7625,9 @@ } }, "node_modules/pino": { - "version": "10.1.1", + "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", @@ -6843,52 +7654,40 @@ } }, "node_modules/pino-pretty": { - "version": "10.3.1", + "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", - "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", - "dev": true, - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, "node_modules/pino-std-serializers": { - "version": "7.0.0", + "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==", "license": "MIT", "engines": { "node": ">= 6" @@ -7006,14 +7805,6 @@ "node": ">= 0.8.0" } }, - "node_modules/process": { - "version": "0.11.10", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-warning": { "version": "5.0.0", "funding": [ @@ -7045,7 +7836,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", + "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": { @@ -7062,12 +7855,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "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" @@ -7081,10 +7875,16 @@ "license": "MIT" }, "node_modules/range-parser": { - "version": "1.2.1", + "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": { @@ -7107,21 +7907,6 @@ "mz": "^2.7.0" } }, - "node_modules/readable-stream": { - "version": "4.7.0", - "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", "dev": true, @@ -7237,7 +8022,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", + "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": { @@ -7266,10 +8053,13 @@ } }, "node_modules/resolve": { - "version": "1.22.11", + "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" @@ -7284,22 +8074,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7309,21 +8091,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@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": { @@ -7341,13 +8123,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", + "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" }, @@ -7358,25 +8142,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "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", "dev": true, @@ -7430,8 +8195,20 @@ } }, "node_modules/secure-json-parse": { - "version": "2.7.0", + "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": { @@ -7571,7 +8348,8 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "dev": true, + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -7629,7 +8407,8 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7640,19 +8419,22 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/side-channel": { - "version": "1.1.0", + "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" }, @@ -7664,11 +8446,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", + "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" @@ -7727,7 +8511,9 @@ } }, "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.3", + "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": { @@ -7737,6 +8523,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", "license": "MIT", @@ -7754,34 +8549,13 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", - "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "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.20.1" - } - }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "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 - } + "ws": "~8.21.0" } }, "node_modules/socket.io-client": { @@ -7849,7 +8623,9 @@ } }, "node_modules/sonic-boom": { - "version": "4.2.0", + "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" @@ -7897,7 +8673,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", + "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" }, @@ -7954,26 +8732,21 @@ "node": ">= 0.4" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string.prototype.trim": { - "version": "1.2.10", + "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" @@ -7983,14 +8756,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", + "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" @@ -8016,11 +8791,13 @@ } }, "node_modules/strip-json-comments": { - "version": "3.1.1", + "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" @@ -8028,6 +8805,8 @@ }, "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", @@ -8048,6 +8827,8 @@ }, "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" @@ -8091,7 +8872,8 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "dev": true, + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -8129,22 +8911,32 @@ } }, "node_modules/thread-stream": { - "version": "4.0.0", + "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", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -8169,6 +8961,8 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", "engines": { "node": ">=12.0.0" @@ -8204,9 +8998,19 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "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" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", - "dev": true, + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -8246,7 +9050,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", + "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": { @@ -8257,7 +9063,9 @@ } }, "node_modules/ts-essentials": { - "version": "10.1.1", + "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": { @@ -8271,6 +9079,8 @@ }, "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": { @@ -8300,15 +9110,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", + "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": { @@ -8363,16 +9192,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", + "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" @@ -8394,14 +9225,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.53.0", + "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.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.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" @@ -8411,8 +9244,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/unbox-primitive": { @@ -8438,7 +9271,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.16.0", + "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": { @@ -8484,6 +9319,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", "license": "MIT", @@ -8553,9 +9397,9 @@ } }, "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "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" @@ -8584,16 +9428,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.3", + "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "bin": { @@ -8610,7 +9454,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8675,19 +9519,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "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.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@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", @@ -8715,12 +9559,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@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": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8765,14 +9609,16 @@ } }, "node_modules/vitest-mock-extended": { - "version": "3.1.0", + "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" } }, @@ -8791,7 +9637,8 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8865,12 +9712,14 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", + "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", @@ -8957,6 +9806,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "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" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/package.json b/package.json index 05fab126..a63890f6 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.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", @@ -17,7 +18,7 @@ "modern-errors": "^7.1.4", "node-os-utils": "^2.0.3", "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", @@ -37,25 +38,27 @@ "@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": "^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", + "pino-pretty": "^13.1.3", "socket.io-client": "^4.8.3", "supertest": "^7.2.2", "typescript": "^5.9.3", - "typescript-eslint": "^8.52.0", + "typescript-eslint": "^8.57.1", "vitest": "^4.0.16", "vitest-mock-extended": "^3.1.0", "ws": "^8.21.0" @@ -66,9 +69,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/app.ts b/src/app.ts index 8bf50460..cd4499b4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,8 @@ import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.j 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'; @@ -103,22 +105,27 @@ const configureWebsocket = (io: WebsocketServer, container: Container { - io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); + io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); + 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); + io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); + 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); + io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); + 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) => { - io.emit(SettingsEventType.changed, serializer.transform(settings)); + io.emit(SettingsEventType.changed, serializer.transform(settings)); }); // Automation events @@ -127,6 +134,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'); @@ -138,6 +146,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 => ({ @@ -198,7 +207,6 @@ export const createApp = (container: Container, options: AppOptions) configureWebsocket(websocketServer, container); loadDeviceProviders(container); - let serveResult: ServeResult | undefined; let canBeShutDown = false; @@ -248,23 +256,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/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 561b02f6..5e4a0d7b 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, @@ -38,26 +32,59 @@ 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(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[] + * - 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) { + if (typeof arg === 'string') { + return arg; + } + + if (typeof arg === 'function') { + return arg.toString(); + } + + if (arg === null || typeof arg !== 'object') { + 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) => { + 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) { @@ -69,17 +96,21 @@ async function __resolveAttr(deviceId, attributeName) { return json !== null ? JSON.parse(json) : null; } -function __createDeviceProxy(id, name) { +function __createDeviceProxy(deviceJson) { + const d = JSON.parse(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 } }); - return Promise.resolve(); + async setAttribute(attributeName, value) { + await __setAttribute.apply( + undefined, + [d.id, attributeName, value], + { arguments: { copy: true }, result: { promise: true } } + ); } }); } @@ -88,19 +119,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)); + const all = JSON.parse(__getDevicesJson.applySync(undefined, [], { result: { copy: true } })); + return all.map(d => __createDeviceProxy(JSON.stringify(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 +164,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)); }); }; `; @@ -228,10 +258,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) => console.error('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 => { @@ -362,7 +392,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 +411,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) { @@ -403,7 +432,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/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..1808b597 100644 --- a/src/controller/automation/deleteScriptController.ts +++ b/src/controller/automation/deleteScriptController.ts @@ -3,6 +3,9 @@ import ControllerInterface from '../controllerInterface.js'; import AutomationScriptRepositoryInterface from '../../repository/automationScriptRepositoryInterface.js'; import { isValidAutomationScriptFileName } from '../../automation/utils.js'; +type RequestParams = { fileName: string }; +type DeleteScriptRequest = Request; + export default class DeleteScriptController implements ControllerInterface { private readonly automationScriptRepository: AutomationScriptRepositoryInterface; @@ -13,16 +16,16 @@ 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; - 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..9d728b62 100644 --- a/src/controller/automation/getScriptController.ts +++ b/src/controller/automation/getScriptController.ts @@ -3,6 +3,9 @@ import ControllerInterface from '../controllerInterface.js'; import AutomationScriptRepositoryInterface from '../../repository/automationScriptRepositoryInterface.js'; import { isValidAutomationScriptFileName } from '../../automation/utils.js'; +type RequestParams = { fileName: string }; +type GetScriptRequest = Request; + export default class GetScriptController implements ControllerInterface { private readonly automationScriptRepository: AutomationScriptRepositoryInterface; @@ -13,16 +16,16 @@ 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; - 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 d4cf8254..b347724d 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 async execute(req: Request, res: Response): Promise + public async execute(req: RunScriptRequest, res: Response): Promise { 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; await this.scriptRuntime.load(scriptCode); diff --git a/src/controller/getDeviceController.ts b/src/controller/getDeviceController.ts index 285ed4f8..b6b3ecb8 100644 --- a/src/controller/getDeviceController.ts +++ b/src/controller/getDeviceController.ts @@ -3,6 +3,9 @@ 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: DeviceId }>; export default class GetDeviceController implements ControllerInterface { @@ -16,10 +19,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..558593fe 100644 --- a/src/controller/patchDeviceController.ts +++ b/src/controller/patchDeviceController.ts @@ -1,8 +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'; +import { DeviceId } from '../device/deviceId.js'; + +type PatchDeviceRequest = Request<{ deviceId: DeviceId }, any, DeviceData>; export default class PatchDeviceController implements ControllerInterface { @@ -16,10 +20,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 +31,11 @@ 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.status(500).send(error.message); + return; } res.sendStatus(202); 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/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..a3daac90 100644 --- a/src/device/attribute/strDeviceAttribute.ts +++ b/src/device/attribute/strDeviceAttribute.ts @@ -18,11 +18,13 @@ export default class StrDeviceAttribute> = + D extends BleDevice ? TAttrs : DeviceAttributes; + +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 +{ + private readonly peripheral: Peripheral; + + private readonly rssiInterval: NodeJS.Timeout; + private readonly reconnectHandler: () => void; + private closing: boolean = false; + + @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; + + this.rssiInterval = setInterval(asyncHandler( + async () => await this.requestRssiUpdate(), + (e: unknown) => logError(this.logger,`Error during RSSI update for device ${this.deviceId}`, e) + ), 5000); + + this.reconnectHandler = asyncHandler( + async () => { + this.logger.info(`BLE Device ${this.deviceId} disconnected, trying to reconnect`); + try { + if (peripheral.state !== 'connected') { + 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}`); + } + } catch (e) { + const error = BaseError.normalize(e); + this.logger.warn(`Error reconnecting to device ${this.deviceId}: ${error.message}`); + await this.close(); + } + }, + (err: unknown) => logError(this.logger, `Error in reconnect handler for device ${this.deviceId}`, err) + ); + + this.peripheral.on('disconnect', this.reconnectHandler); + } + + private async requestRssiUpdate(): Promise { + if (this.closing || this.peripheral.state === 'disconnected') { + return; + } + + try { + const rssi = await promiseWithTimeout(this.peripheral.updateRssiAsync(), 750, `Timed out (>750ms) while updating RSSI for device ${this.deviceId}`); + this.logger.trace(`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 { + 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') { + this.peripheral.cancelConnect(); + } + + clearInterval(this.rssiInterval); + this.peripheral.off('disconnect', this.reconnectHandler); + } +} diff --git a/src/device/device.ts b/src/device/device.ts index 78111190..14fef55e 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -3,22 +3,36 @@ import DeviceState from './deviceState.js'; 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> = - 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 ExtractAttributeValue = A extends DeviceAttribute ? V : never; -type AttributeKey = T extends { value: any } ? T : never; +export type DeviceNotifications = JsonObject; +export type NoDeviceNotifications = Record; +export type AnyDeviceNotifications = JsonObject; + +export type AttributeKeyOf = keyof A & string; +export type AttributeValueOf> = + NonNullable['value']; + +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 = { @@ -29,23 +43,32 @@ 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() - protected readonly connectedSince: Date; + protected readonly deviceId: DeviceId; @Expose() - protected readonly deviceId: string; + protected readonly connectedSince: Date; @Expose() protected readonly deviceName: string; @@ -77,7 +100,7 @@ export default abstract class Device< private eventEmitter: EventEmitter; protected constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, provider: string, connectedSince: Date, @@ -97,7 +120,7 @@ export default abstract class Device< this.state = DeviceState.ready; } - public get getDeviceId(): string { + public get getDeviceId(): DeviceId { return this.deviceId; } @@ -142,16 +165,16 @@ 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, - V extends ExtractAttributeValue - >(attributeName: K, value: V): Promise; + public abstract setAttribute>( + 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); } @@ -166,7 +189,7 @@ export default abstract class Device< await this.doClose(); } finally { this.state = DeviceState.closed; - this.emit(DeviceEvent.deviceDisconnected, this); + this.emit(DeviceEvent.deviceDisconnected); } } @@ -178,11 +201,17 @@ 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( + 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/deviceId.ts b/src/device/deviceId.ts new file mode 100644 index 00000000..de4ff5a0 --- /dev/null +++ b/src/device/deviceId.ts @@ -0,0 +1,13 @@ +import { v5 as uuidv5 } from 'uuid'; + +const DEVICE_NAMESPACE = '1e0758c9-799d-40b5-b2fc-63f1e66afb76'; +const deviceIdSymbol = Symbol(); + +export type DeviceId = string & { [deviceIdSymbol]: never } + +export const 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/deviceManager.ts b/src/device/deviceManager.ts index f1d4377a..44082578 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -1,18 +1,15 @@ -import Device, { DeviceAttributes, DeviceEvent } from './device.js'; +import Device, { DeviceAttributes, DeviceEvent, DeviceNotification, DeviceNotifications } 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'; import { AnyDeviceConfig } from './deviceConfig.js'; import { logError } from '../util/error.js'; +import { DeviceId } from './deviceId.js'; export type DeviceInfo = { - id: string; -}; - -export type SerialDeviceInfo = DeviceInfo & { - portInfo: PortInfo; + type: string; + id: DeviceId; }; export enum DeviceManagerEvent { @@ -20,6 +17,7 @@ export enum DeviceManagerEvent { deviceDisconnected = 'deviceDisconnected', deviceRefreshed = 'deviceRefreshed', deviceDetected = 'deviceDetected', + deviceNotification = 'deviceNotification', } type AcquireResult = @@ -31,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 @@ -41,11 +40,11 @@ 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; - this.logger = logger; + this.logger = logger.child({ name: DeviceManager.name }); this.connectedDevices = connectedDevices; } @@ -55,6 +54,13 @@ export default class DeviceManager return; } + 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, []); const hadListeners = this.eventEmitter.emit(DeviceManagerEvent.deviceDetected, deviceInfo); @@ -71,7 +77,7 @@ export default class DeviceManager this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); } - public async acquireDetectedDevice(deviceId: string): Promise + public async acquireDetectedDevice(deviceId: DeviceId): Promise { return new Promise((resolve) => { const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); @@ -91,7 +97,7 @@ export default class DeviceManager }); } - public releaseDetectedDevice(deviceId: string): void + public releaseDetectedDevice(deviceId: DeviceId): void { const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); @@ -110,21 +116,22 @@ 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); 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`); } @@ -134,9 +141,9 @@ export default class DeviceManager return Array.from(this.connectedDevices.values()); } - public getConnectedDevice(uuid: string): Device|null + public getConnectedDevice(deviceId: string): Device|null { - const device = this.connectedDevices.get(uuid); + const device = this.connectedDevices.get(deviceId); return undefined !== device ? device : null; } @@ -159,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 @@ -177,7 +197,7 @@ export default class DeviceManager this.detectedDeviceAcquireQueue.delete(deviceId); } - private initDeviceRefresher(device: Device): void { + private initDeviceRefresher(device: Device): void { this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId})`); const deviceRefreshIntervalMs = device.getRefreshInterval; @@ -205,13 +225,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/genericDeviceUpdater.ts b/src/device/genericDeviceUpdater.ts index c969117b..700c4945 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); @@ -16,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)) { @@ -27,11 +31,24 @@ 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.logger.info(`${deviceLogMsg} -> done`); } catch(e: unknown) { + 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 (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/peripheralDevice.ts b/src/device/peripheralDevice.ts index 5962bb97..6afd3e8b 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -1,33 +1,35 @@ -import Device, { DeviceAttributes } from './device.js'; -import DeviceTransport from './transport/deviceTransport.js'; -import DeviceProtocol, { MessageResponse } from './protocol/deviceProtocol.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>, + TProtocol extends DeviceProtocol>, TAttributes extends DeviceAttributes = DeviceAttributes, + TNotifications extends DeviceNotifications = NoDeviceNotifications, TConfig extends AnyDeviceConfig = NoDeviceConfig -> extends Device +> extends Device { - protected readonly transport: DeviceTransport; + protected readonly transport: BidirectionalDeviceTransport; protected readonly protocol: TProtocol; protected constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, provider: string, 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 new file mode 100644 index 00000000..94e03b60 --- /dev/null +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -0,0 +1,248 @@ +import { Exclude } from 'class-transformer'; +import EventEmitter from 'events'; +import { AttributeKeyOf, AttributeValueOf, DeviceEvent } 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'; +import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; +import { sleep } from '../../../util/async.js'; +import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; +import { Float } from '../../../util/numbers.js'; +import typeDetect from 'type-detect'; + +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'; + +export type AiroticDeviceAttributes = { + restColor: StrDeviceAttribute, + breathInColor: StrDeviceAttribute, + resetColors: BoolDeviceAttribute, + reboot: BoolDeviceAttribute, + breathsPerMin: FloatDeviceAttribute, + bpmTrend: StrDeviceAttribute, +}; + +export type AiroticDeviceNotifications = { + colorChange: { + colorType: 'breathInColor' | 'restColor'; + }; +}; + +@Exclude() +export default class AiroticDevice extends BleDevice +{ + private readonly messageResponseHandler: MessageResponseHandler; + + private readonly transport: BleUartDeviceTransport; + + private readonly breathTimestamps: number[] = []; + + private breathTimeoutHandle: ReturnType | null = null; + + public constructor( + deviceId: DeviceId, + deviceName: string, + provider: string, + peripheral: Peripheral, + transport: BleUartDeviceTransport, + messageResponseHandler: MessageResponseHandler, + connectedSince: Date, + controllable: boolean, + attributes: AiroticDeviceAttributes, + config: NoDeviceConfig, + eventEmitter: EventEmitter, + logger: Logger, + ) { + super(deviceId, deviceName, provider, peripheral, connectedSince, controllable, attributes, config, eventEmitter, logger); + + this.transport = transport; + this.messageResponseHandler = messageResponseHandler; + + 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.trace(`Received data from device ${this.deviceId}: ${dataStr}`); + + this.emit(DeviceEvent.deviceNotification, { + type: 'colorChange', + data: { + colorType: dataStr === '*B' ? 'breathInColor' : 'restColor', + }, + }); + + if (dataStr === '*B') { + this.recordBreath(); + } + } + + private recordBreath(): void { + const now = Date.now(); + + this.breathTimestamps.push(now); + + this.resetBreathTimeout(); + this.recalculateBreathsPerMin(); + this.updateLastRefresh(); + } + + private resetBreathTimeout(): void { + if (this.breathTimeoutHandle !== null) { + clearTimeout(this.breathTimeoutHandle); + } + + this.breathTimeoutHandle = setTimeout(() => { + 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'; + } + + 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()); + await sleep(100); + await this.messageResponseHandler.send(AiroticProtocol.createSetColorMessage(r, g, b)); + this.attributes.restColor.value = value; + return value; + } + + if (attributeName === 'breathInColor' && value !== null && typeof value === 'string') { + 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)); + this.attributes.breathInColor.value = value; + return value; + } + + if (attributeName === 'resetColors' && typeof value === 'boolean') { + if (value) { + await this.messageResponseHandler.send(AiroticProtocol.createResetColorsMessage()); + this.attributes.restColor.value = DEFAULT_REST_COLOR; + this.attributes.breathInColor.value = DEFAULT_BREATH_IN_COLOR; + this.updateLastRefresh(); + } + return value; + } + + if (attributeName === 'reboot' && typeof value === 'boolean') { + if (value) { + await this.messageResponseHandler.send(AiroticProtocol.createRebootMessage()); + await sleep(500); + await this.close(); + } + + return value; + } + + throw new Error(`Unknown attribute '${attributeName}' or invalid value type '${typeDetect(value)}'`); + } + + public override async doClose(): Promise { + if (this.breathTimeoutHandle !== null) { + clearTimeout(this.breathTimeoutHandle); + this.breathTimeoutHandle = null; + } + + await super.doClose(); + await this.transport.close(); + } + + private parseColor(value: string): { r: number, g: number, b: number } { + 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) { + 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 new file mode 100644 index 00000000..12f8730f --- /dev/null +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -0,0 +1,140 @@ +import EventEmitter from 'events'; +import BaseError from 'modern-errors'; +import DeviceManager from '../../deviceManager.js'; +import AiroticDevice from './airoticDevice.js'; +import Logger from '../../../logging/Logger.js'; +import { promiseWithTimeout } from '../../../util/async.js'; +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 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'; +import { hsvByteToRgb } from '../../../util/color.js'; + +export default class AiroticDeviceProvider extends BleDeviceProvider +{ + public static readonly providerName = 'airotic'; + + private static readonly UART_RX_CHAR_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; + private static readonly UART_TX_CHAR_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; + + 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; + } + + public override async init(): Promise { + this.logger.debug('Initialized AiroticDeviceProvider'); + } + + 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}`); + + this.logger.debug(`Connected to device: ${deviceInfo.id}`); + + const protocol = new AiroticProtocol(); + const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger, 2000); + + const handshakeSucceeded = await this.doHandshake(messageResponseHandler); + + if (!handshakeSucceeded) { + await transport.close(); + return undefined; + } + + const knownDevice = this.createKnownDevice( + deviceInfo.id, + deviceInfo.peripheral.advertisement.localName ?? `Airotic ${deviceInfo.id}`, + ); + + const advertisedColors = this.parseAdvertisedColors(deviceInfo.peripheral.advertisement.manufacturerData); + + const device = new AiroticDevice( + knownDevice.id, + knownDevice.name, + AiroticDeviceProvider.providerName, + deviceInfo.peripheral, + transport, + messageResponseHandler, + new Date(), + true, + { + restColor: StrDeviceAttribute.create('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, advertisedColors?.restColor), + breathInColor: StrDeviceAttribute.create('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, advertisedColors?.breathInColor), + resetColors: BoolDeviceAttribute.create('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly), + reboot: BoolDeviceAttribute.create('reboot', 'Reboot bottle', DeviceAttributeModifier.writeOnly), + breathsPerMin: FloatDeviceAttribute.create('breathsPerMin', 'Breaths/min', DeviceAttributeModifier.readOnly, 'breaths/min'), + bpmTrend: StrDeviceAttribute.create('bpmTrend', 'BPM Trend', DeviceAttributeModifier.readOnly), + }, + {}, + new EventEmitter(), + this.logger, + ); + + this.settings.addKnownDevice(knownDevice); + + return device; + } + + /** + * Parses colors broadcast in the BLE advertising manufacturer data payload, so + * initial rest/breath-in colors can be reflected without needing a UART round-trip. + * Byte layout (0-indexed): 3 = colorTarget.h, 4 = colorTarget.s, 5 = colorStart.h, 6 = colorStart.s + */ + private parseAdvertisedColors(manufacturerData: Buffer | undefined): { restColor?: string, breathInColor?: string } | undefined { + if (undefined === manufacturerData || manufacturerData.length < 7) { + return undefined; + } + + const colorStartH = manufacturerData[3]; + const colorStartS = manufacturerData[4]; + const colorTargetH = manufacturerData[5]; + const colorTargetS = manufacturerData[6]; + + const target = hsvByteToRgb(colorTargetH, colorTargetS, 255); + const start = hsvByteToRgb(colorStartH, colorStartS, 255); + + return { + restColor: `${start.r},${start.g},${start.b}`, + breathInColor: `${target.r},${target.g},${target.b}`, + }; + } + + private async doHandshake(messageResponseHandler: MessageResponseHandler): Promise { + try { + 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); + this.logger.info(`Handshake failed: ${error.message}`); + } + + return false; + } + + private createKnownDevice(deviceId: DeviceId, deviceName: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice) { + this.logger.debug(`Device is already known: ${knownDevice.id}`); + return knownDevice; + } + + return new KnownDevice(deviceId, deviceName, 'airotic', AiroticDeviceProvider.providerName); + } +} diff --git a/src/device/protocol/airotic/airtonicProtocol.ts b/src/device/protocol/airotic/airtonicProtocol.ts new file mode 100644 index 00000000..3024cc32 --- /dev/null +++ b/src/device/protocol/airotic/airtonicProtocol.ts @@ -0,0 +1,52 @@ +import DeviceProtocol, { DecodeResult, Message, MessageWithResponse } from '../deviceProtocol.js'; + +export type AiroticProtocolMessageWithResponse = MessageWithResponse; +export type AiroticProtocolMessageWithoutResponse = Message; +export type AiroticProtocolMessage = AiroticProtocolMessageWithResponse | AiroticProtocolMessageWithoutResponse; + +export default class AiroticProtocol implements DeviceProtocol +{ + public encode(message: Buffer): Buffer { + return message; + } + + public decode(data: Buffer): DecodeResult { + return { + message: data.toString('utf8'), + } + } + + 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(): 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/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index f14ade94..a1b69bb5 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -1,12 +1,13 @@ import { Exclude, Expose } from 'class-transformer'; import { ActuatorType, ButtplugClientDevice, SensorType } from 'buttplug'; -import Device, { ExtractAttributeValue } 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'; 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, @@ -52,8 +53,8 @@ export default class ButtplugIoDevice extends Device } public async setAttribute< - K extends keyof ButtplugIoDeviceAttributes & string, - V extends ExtractAttributeValue + K extends AttributeKeyOf, + V extends AttributeValueOf >(attributeName: K, value: V): Promise { const attribute = this.attributes[attributeName]; @@ -83,13 +84,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/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index 5e9b236c..ea5a4e43 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; @@ -60,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( @@ -84,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. @@ -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 = DeviceId.create(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}`); return knownDevice; } return new KnownDevice( - this.uuidFactory.create(), deviceId, buttplugDevice.displayName ?? buttplugDevice.name, buttplugDevice.name, diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 82105bd9..6438929d 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -10,7 +10,7 @@ import DeviceManager from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; import { hasProperty } from '../../../util/objects.js'; -export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { +export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { public static readonly providerName = 'buttplugIoWebsocket'; private connectedDevices: Map = new Map(); @@ -129,7 +129,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider = | { 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/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index a36b0b7e..b28e89f5 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 { 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'; @@ -7,11 +7,12 @@ 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'; import Logger from '../../../logging/Logger.js'; +import { DeviceId } from '../../deviceId.js'; export type EStim2bDeviceAttributes = { mode: ListDeviceAttribute, @@ -26,6 +27,8 @@ export type EStim2bDeviceAttributes = { export type EStim2bBatteryStatus = 'mains' | 'full' | 'medium' | 'low' | 'critical'; +type AttributeValue = AttributeValueOf; + @Exclude() export default class EStim2bDevice extends PeripheralDevice { @@ -36,14 +39,14 @@ export default class EStim2bDevice extends PeripheralDevice - >(attributeName: K, value: V): Promise { - const attribute = this.attributes[attributeName] + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValue): Promise> { + const attribute = this.attributes[attributeName]; if (undefined === attribute) { throw new Error(`Attribute '${attributeName}' does not exist`); @@ -130,7 +132,7 @@ export default class EStim2bDevice extends PeripheralDevice diff --git a/src/device/protocol/estim2b/estim2bDeviceFactory.ts b/src/device/protocol/estim2b/estim2bDeviceFactory.ts index e8d45be1..012fc906 100644 --- a/src/device/protocol/estim2b/estim2bDeviceFactory.ts +++ b/src/device/protocol/estim2b/estim2bDeviceFactory.ts @@ -1,4 +1,3 @@ -import UuidFactory from '../../../factory/uuidFactory.js'; import Settings from '../../../settings/settings.js'; import DeviceNameGenerator from '../../deviceNameGenerator.js'; import DateFactory from '../../../factory/dateFactory.js'; @@ -12,13 +11,12 @@ import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js' import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import ListDeviceAttribute from '../../attribute/listDeviceAttribute.js'; -import DeviceTransport from '../../transport/deviceTransport.js'; +import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; +import { DeviceId } from '../../deviceId.js'; export default class Estim2bDeviceFactory { - private readonly uuidFactory: UuidFactory; - private readonly dateFactory: DateFactory; private readonly settings: Settings; @@ -30,14 +28,12 @@ export default class Estim2bDeviceFactory private readonly eventEmitterFactory: EventEmitterFactory; public constructor( - uuidFactory: UuidFactory, dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, settings: Settings, nameGenerator: DeviceNameGenerator, logger: Logger ) { - this.uuidFactory = uuidFactory; this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; @@ -47,15 +43,16 @@ export default class Estim2bDeviceFactory } public async create( + deviceId: DeviceId, protocol: EStim2bProtocol, - transport: DeviceTransport, + transport: DeviceBidirectionalTransport, initialStatus: EStim2bStatus, provider: string ): Promise { const attributes = this.getAttributes(initialStatus); return new Estim2bDevice( - this.uuidFactory.create(), + deviceId, this.nameGenerator.generateName(), provider, this.dateFactory.now(), 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/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index 3696db73..e16f64c6 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -1,6 +1,6 @@ import { ReadlineParser } from 'serialport'; import { SerialPortStream } from '@serialport/stream'; -import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import { BindingInterface } from '@serialport/bindings-interface'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; @@ -12,7 +12,7 @@ import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import DeviceManager from '../../deviceManager.js'; -import { DeviceEvent } from '../../device.js'; +import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -20,8 +20,6 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider = new Map(); - private readonly deviceFactory: EStim2bDeviceFactory; public constructor( @@ -38,9 +36,9 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider, portInfo: PortInfo): Promise { + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): 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(); @@ -54,27 +52,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/messageResponseHandler.ts b/src/device/protocol/messageResponseHandler.ts index 74c34954..598acbc1 100644 --- a/src/device/protocol/messageResponseHandler.ts +++ b/src/device/protocol/messageResponseHandler.ts @@ -1,39 +1,45 @@ -import DeviceProtocol, { InferMR, InferResponse, MessageResponse } from './deviceProtocol.js'; -import DeviceTransport from '../transport/deviceTransport.js'; +import DeviceProtocol, { InferMR, InferResponse, MessageWithOptionalResponse, MessageWithResponse } from './deviceProtocol.js'; +import DeviceBidirectionalTransport from '../transport/deviceBidirectionalTransport.js'; import { clearTimeout } from 'node:timers'; import Logger from '../../logging/Logger.js'; +import { promiseWithTimeout } from '../../util/async.js'; type PendingEntry = { msg: MR; resolve: (response: InferResponse) => void; reject: (error: Error) => void; timeout: ReturnType; + timeoutMs: number, pendingSince: number, }; -export default class MessageResponseHandler

>> +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: DeviceProtocol, - transport: DeviceTransport, + public static create

>>( + protocol: P, + transport: DeviceBidirectionalTransport, logger: Logger, - ): MessageResponseHandler> { - return new this(protocol, transport, logger); + timeoutMs: number = 200, + ): MessageResponseHandler

{ + return new this(protocol, transport, logger, timeoutMs); } private constructor( protocol: P, - transport: DeviceTransport, + transport: DeviceBidirectionalTransport, 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 () => { @@ -62,22 +68,38 @@ 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)}` ); } - return; + break; } } } + private isMessageWithResponse>( + msg: T + ): msg is Extract> { + return 'responseType' in msg; + } + public async send>( 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) => { + this.transport.send(encodedMsg).then(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + resolve(undefined as InferResponse); + }).catch(reject); + }), realTimeoutMs, `Message ${encodedMsg.toString()} timed out after ${realTimeoutMs}ms`); + } return new Promise>((resolve, reject) => { const entry = { @@ -87,9 +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), + }, realTimeoutMs), + timeoutMs: realTimeoutMs, pendingSince: Date.now(), }; diff --git a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts index 4cd59a89..5e945d9d 100644 --- a/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts @@ -1,11 +1,14 @@ import { Exclude, Expose } from 'class-transformer'; import SlvCtrlPlusDevice, { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; import DeviceState from '../../deviceState.js'; -import { ExtractAttributeValue } from '../../device.js'; +import { AttributeKeyOf, AttributeValueOf } from '../../device.js'; import SlvCtrlProtocol from './slvCtrlProtocol.js'; -import DeviceTransport from '../../transport/deviceTransport.js'; +import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; 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 @@ -23,13 +26,13 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice public constructor( fwVersion: number, - deviceId: string, + deviceId: DeviceId, deviceName: string, deviceModel: string, provider: string, connectedSince: Date, protocol: SlvCtrlProtocol, - transport: DeviceTransport, + transport: DeviceBidirectionalTransport, protocolVersion: number, attributes: SlvCtrlPlusDeviceAttributes, eventEmitter: EventEmitter, @@ -62,17 +65,16 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice } public async setAttribute< - K extends keyof SlvCtrlPlusDeviceAttributes, - V extends ExtractAttributeValue - >(attributeName: K, value: V): Promise { + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValue): Promise> { 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)) { @@ -91,7 +93,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/slvCtrlPlus/slvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts index 75717f89..c4069047 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts @@ -1,12 +1,14 @@ 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 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'; import Logger from '../../../logging/Logger.js'; +import { DeviceId } from '../../deviceId.js'; export type SlvCtrlPlusDeviceAttributeKey = string; export type SlvCtrlPlusDeviceAttributes = Record; @@ -14,17 +16,18 @@ export type SlvCtrlPlusDeviceAttributes = Record extends PeripheralDevice { +> extends PeripheralDevice { protected readonly logger: Logger; protected constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, provider: string, connectedSince: Date, protocol: SlvCtrlProtocol, - transport: DeviceTransport, + transport: DeviceBidirectionalTransport, controllable: boolean, attributes: TAttributes, config: TConfig, diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index 9eaf42a4..bb131470 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -1,10 +1,9 @@ -import UuidFactory from '../../../factory/uuidFactory.js'; import Settings from '../../../settings/settings.js'; import KnownDevice from '../../../settings/knownDevice.js'; import DeviceNameGenerator from '../../deviceNameGenerator.js'; import GenericSlvCtrlPlusDevice from './genericSlvCtrlPlusDevice.js'; import DateFactory from '../../../factory/dateFactory.js'; -import DeviceTransport from '../../transport/deviceTransport.js'; +import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import SlvCtrlProtocolLegacy from './slvCtrlProtocolLegacy.js'; import Logger from '../../../logging/Logger.js'; import SlvCtrlProtocolV1 from './slvCtrlProtocolV1.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: DeviceBidirectionalTransport, 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( @@ -70,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`), @@ -102,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: [] }), @@ -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}`); 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 3c390121..af1f1445 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts @@ -10,9 +10,10 @@ 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'; export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvider { @@ -22,8 +23,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; @@ -41,36 +40,26 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide this.deviceTransportFactory = deviceTransportFactory; } - protected async connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise { + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): 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; } - 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/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 b62f6770..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) { @@ -90,7 +89,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: (status === 'ok' || status === 'error') ? status : 'unknown' }; if (undefined !== reason) { result.reason = reason; @@ -101,7 +100,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 +175,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..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,19 +21,19 @@ 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')); } 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); @@ -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/virtual/audio/piperVirtualDeviceLogic.ts b/src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts index 2255c264..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, } ); @@ -197,8 +197,12 @@ 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}`); - 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; } } @@ -214,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/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 6c0ea7e4..052948ea 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -1,15 +1,17 @@ import { Exclude, Expose } from 'class-transformer'; -import Device, { ExtractAttributeValue } from '../../device.js'; +import BaseError from 'modern-errors'; +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'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; +import { DeviceId } from '../../deviceId.js'; @Exclude() export default class VirtualDevice< TLogic extends VirtualDeviceLogic -> extends Device, ExtractConfig> { +> extends Device, NoDeviceNotifications, ExtractConfig> { @Expose() private deviceModel: string; @@ -24,7 +26,7 @@ export default class VirtualDevice< public constructor( fwVersion: string, - deviceId: string, + deviceId: DeviceId, deviceName: string, deviceModel: string, provider: string, @@ -42,17 +44,22 @@ 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); } 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; } } @@ -61,8 +68,8 @@ export default class VirtualDevice< } public async setAttribute< - K extends keyof ExtractAttributes, - V extends ExtractAttributeValue[K]> + K extends AttributeKeyOf>, + V extends AttributeValueOf, K> >(attributeName: K, value: V): Promise { return new Promise((resolve, reject) => { this.state = DeviceState.busy; @@ -71,7 +78,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}" does not exist for device with id "${this.deviceId}"` )); return; } 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 4806d93a..fbcaa97a 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'; @@ -25,6 +25,8 @@ export default class VirtualDeviceProvider extends DeviceProvider { + 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,13 +51,23 @@ export default class VirtualDeviceProvider extends DeviceProvider { + this.stopped = true; + if (this.discoveryInterval !== undefined) { clearInterval(this.discoveryInterval); this.discoveryInterval = undefined; } + + for (const device of this.connectedDevices.values()) { + await this.removeDevice(device); + } } private async discoverVirtualDevices(): Promise { + if (this.stopped) { + return; + } + const settings = this.settingsManager.getSettings(); if (undefined === settings) { @@ -72,6 +86,10 @@ export default class VirtualDeviceProvider extends DeviceProvider; 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> & Required; + +type AnyZc95DeviceAttribute = DeviceAttributeOf; + +type AttributeValue = AttributeValueOf; + @Exclude() export default class Zc95Device extends PeripheralDevice { @@ -51,10 +59,6 @@ export default class Zc95Device extends PeripheralDevice - >(attributeName: K, value: V): Promise { - if (attributeName === 'activePattern' && this.attributes.activePattern.isValidValue(value)) { + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValue): Promise> { + const attribute = this.attributes[attributeName]; + + if (!this.isAttributePresent(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; + return attribute.value; } - const patternDetailAttrMatch = Zc95Device.patternDetailAttributeRegex.exec(attributeName); - - if (patternDetailAttrMatch && typeof value === 'number') { - await this.setAttributePatternDetail(patternDetailAttrMatch, value); + if (this.isPatternDetailAttribute(attribute) && attribute.isValidValue(value)) { + await this.setAttributePatternDetail(attribute, value); this.updateLastRefresh(); - return value; + return attribute.value; } throw new Error( @@ -124,10 +131,13 @@ export default class Zc95Device extends PeripheralDevice { - const attributeName = attributeNameMatch[0] as keyof Zc95DevicePatternAttributes; - const patternDetailAttr = await this.getAttribute(attributeName); - const menuItemId = parseInt(attributeNameMatch[1], 10); + private async setAttributePatternDetail(patternDetailAttr: DeviceAttributeOf, value: number): Promise { + + 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); @@ -139,27 +149,27 @@ export default class Zc95Device extends PeripheralDevice, value: number ): Promise { if (!this.allPowerChannelValuesDefined(this.attributes)) { - return; + throw new Error('Cannot set channel power before all channel values have been initialized'); } - 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, @@ -170,7 +180,7 @@ export default class Zc95Device extends PeripheralDevice): attrs is { @@ -225,23 +235,24 @@ 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'] & { name: 'patternStarted' } { + return attribute?.name === 'patternStarted'; + } + + private isActivePatternAttribute(attribute: AnyZc95DeviceAttribute): attribute is Zc95DeviceAttributes['activePattern'] & { name: 'activePattern' } { + return attribute?.name === 'activePattern'; + } + + private isPatternDetailAttribute(attribute: AnyZc95DeviceAttribute): attribute is DeviceAttributeOf { + return attribute !== undefined + && attribute.name.startsWith(Zc95Device.patternAttributePrefix) + && !isNaN(parseInt(attribute.name.slice(Zc95Device.patternAttributePrefix.length), 10)); } private isPowerStatusMessage(msg: MsgResponse): msg is PowerStatusMsgResponse { diff --git a/src/device/protocol/zc95/zc95DeviceFactory.ts b/src/device/protocol/zc95/zc95DeviceFactory.ts index 2bbb0110..3b02f57e 100644 --- a/src/device/protocol/zc95/zc95DeviceFactory.ts +++ b/src/device/protocol/zc95/zc95DeviceFactory.ts @@ -1,4 +1,3 @@ -import UuidFactory from '../../../factory/uuidFactory.js'; import Settings from '../../../settings/settings.js'; import DeviceNameGenerator from '../../deviceNameGenerator.js'; import DateFactory from '../../../factory/dateFactory.js'; @@ -10,15 +9,14 @@ import ListDeviceAttribute, { ListDeviceAttributeOptions } from '../../attribute import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import { Int } from '../../../util/numbers.js'; import Zc95Protocol from './zc95Protocol.js'; -import DeviceTransport from '../../transport/deviceTransport.js'; +import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { logError } from '../../../util/error.js'; +import { DeviceId } from '../../deviceId.js'; export default class Zc95DeviceFactory { - private readonly uuidFactory: UuidFactory; - private readonly dateFactory: DateFactory; private readonly eventEmitterFactory: EventEmitterFactory; @@ -30,14 +28,12 @@ export default class Zc95DeviceFactory 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; @@ -46,9 +42,10 @@ export default class Zc95DeviceFactory } public async create( + deviceId: DeviceId, versionDetails: VersionMsgResponse, protocol: Zc95Protocol, - transport: DeviceTransport, + transport: DeviceBidirectionalTransport, messageFactory: Zc95MessageFactory, messageResponseHandler: MessageResponseHandler, provider: string @@ -67,7 +64,7 @@ export default class Zc95DeviceFactory // this.settings.addKnownDevice(knownDevice); return new Zc95Device( - this.uuidFactory.create(), + deviceId, this.nameGenerator.generateName(), provider, this.dateFactory.now(), diff --git a/src/device/protocol/zc95/zc95MessageFactory.ts b/src/device/protocol/zc95/zc95MessageFactory.ts index cf59563a..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', } @@ -138,7 +112,7 @@ export interface PatternsMsgResponse extends MsgResponse interface ChannelPowerStatus { - Channel: number; + Channel: 1 | 2 | 3 | 4; OutputPower: number; MaxOutputPower: number; PowerLimit: number; @@ -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 d860c3b1..768dec34 100644 --- a/src/device/protocol/zc95/zc95Protocol.ts +++ b/src/device/protocol/zc95/zc95Protocol.ts @@ -1,17 +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')); @@ -19,17 +47,34 @@ 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/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index 8a0077ae..71703ee3 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -1,5 +1,5 @@ import { SerialPortStream } from '@serialport/stream'; -import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import { BindingInterface } from '@serialport/bindings-interface'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; @@ -13,13 +13,12 @@ import MessageResponseHandler from '../messageResponseHandler.js'; import Zc95MessageFactory from './zc95MessageFactory.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import DeviceManager from '../../deviceManager.js'; +import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { public static readonly providerName = 'zc95Serial'; - private connectedDevices: Map = new Map(); - private readonly transportFactory: SerialDeviceTransportFactory; private readonly deviceFactory: Zc95DeviceFactory; @@ -38,11 +37,11 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider, portInfo: PortInfo): Promise { + 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 })); - 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 +58,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/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts new file mode 100644 index 00000000..b7b36009 --- /dev/null +++ b/src/device/provider/bleDeviceProvider.ts @@ -0,0 +1,94 @@ +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, DeviceNotifications, InferDeviceNotifications } from '../device.js'; +import { AnyDeviceConfig } from '../deviceConfig.js'; + +export default abstract class BleDeviceProvider< + D extends BleDevice, + TAttributes extends DeviceAttributes = InferBleDeviceAttributes, + TNotifications extends DeviceNotifications = InferDeviceNotifications, + TConfig extends AnyDeviceConfig = InferBleDeviceConfig +> extends DeviceProvider +{ + private connectedDevices: Set = new Set(); + + 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.connectedDevices.add(device); + 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); + } + } + + 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'; + } + + 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); + } + } else if (peripheral.state === 'connecting') { + peripheral.cancelConnect(); + } + } + + protected abstract connectBleDevice(deviceInfo: BleDeviceInfo): Promise; +} diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index c49d3e0b..5292c4ad 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/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index f6f3aa88..1620a8e1 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -1,28 +1,22 @@ import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; -import DeviceManager from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; import DeviceProvider from './deviceProvider.js'; -import Device from '../device.js'; export default class DeviceProviderManager { - private factories: Map; - - private readonly deviceManager: DeviceManager; + private factories: Map>; private readonly logger: Logger; - private providers: DeviceProvider[] = []; + private providers: DeviceProvider[] = []; public constructor( - deviceManager: DeviceManager, - factories: Map, + factories: Map>, logger: Logger ) { - this.deviceManager = deviceManager; this.factories = factories; - this.logger = logger; + this.logger = logger.child({ name: DeviceProviderManager.name }); } public loadFromSettings(settings: Settings): void @@ -35,7 +29,7 @@ export default class DeviceProviderManager 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/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 13e17839..c3d0769e 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -7,23 +7,26 @@ import { SerialPortStream } from '@serialport/stream'; 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 PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; -import { AnyDeviceConfig } from '../deviceConfig.js'; -import { DeviceAttributes } from '../device.js'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.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'>; export default abstract class SerialDeviceProvider< - D extends PeripheralDevice, + D extends PeripheralDevice, TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig -> extends DeviceProvider +> extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; + private connectedDevices: Map = new Map(); + private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { @@ -48,13 +51,13 @@ 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; } try { - const device = await this.connectToDevice(deviceInfo.portInfo); + const device = await this.connectToDevice(deviceInfo); if (undefined === device) { this.deviceManager.releaseDetectedDevice(deviceInfo.id); @@ -71,10 +74,12 @@ export default abstract class SerialDeviceProvider< private isSerialDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is SerialDeviceInfo { - return 'portInfo' in deviceInfo; + return deviceInfo.type === 'serial'; } - 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({ @@ -93,7 +98,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 { @@ -115,6 +120,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, device); + + 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); + + this.logger.info(`Lost serial device: ${device.getDeviceId}`); + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); + }); } return device; @@ -129,7 +146,7 @@ export default abstract class SerialDeviceProvider< this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } - protected abstract connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise; + protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPortStream): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts new file mode 100644 index 00000000..042c8923 --- /dev/null +++ b/src/device/transport/bleDeviceTransport.ts @@ -0,0 +1,164 @@ +import { Characteristic, Peripheral } from '@stoprocent/noble'; +import DeviceBidirectionalTransport from './deviceBidirectionalTransport.js'; +import { asyncHandler } from '../../util/async.js'; + +export default class BleUartDeviceTransport implements DeviceBidirectionalTransport +{ + private readonly peripheral: Peripheral; + private rx?: Characteristic; + private tx?: Characteristic; + + private readonly uartRxCharacteristicUuid: string; + private readonly uartTxCharacteristicUuid: string; + + private isConnected: boolean = false; + private isSubscribing: boolean = false; + + private onCloseSubscribers: (() => Promise)[] = []; + private onReceiveSubscribers: ((data: Buffer) => void)[] = []; + private onConnectedSubscribers: (() => void)[] = []; + + private readonly connectHandler: (err: Error) => void; + private readonly disconnectHandler: (err: Error) => void; + + public static async create( + peripheral: Peripheral, + uartRxCharacteristicUuid: string, + uartTxCharacteristicUuid: string + ): Promise { + const transport = new this(peripheral, uartRxCharacteristicUuid, uartTxCharacteristicUuid); + await transport.subscribe(); + return transport; + } + + private constructor(peripheral: Peripheral, uartRxCharacteristicUuid: string, uartTxCharacteristicUuid: string) { + this.peripheral = peripheral; + this.uartRxCharacteristicUuid = uartRxCharacteristicUuid; + this.uartTxCharacteristicUuid = uartTxCharacteristicUuid; + + 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); + this.peripheral.on('disconnect', this.disconnectHandler); + } + + private async subscribe(): Promise { + if (this.isSubscribing || this.isConnected) { + return; + } + + 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}`); + } + + 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; + + 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; + } + } + + // 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 { + if (!this.isConnected || !this.rx) { + throw new Error('Transport not connected'); + } + await this.rx.writeAsync(data, false); + } + + public onReceive(dataProcessor: (data: Buffer) => void): void { + this.onReceiveSubscribers.push(dataProcessor); + this.tx?.on('data', dataProcessor); + } + + public onClose(callback: () => Promise): void { + this.onCloseSubscribers.push(callback); + } + + public onConnected(callback: () => void): void { + this.onConnectedSubscribers.push(callback); + } + + public isOpen(): boolean { + return this.isConnected; + } + + 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); + } + try { + await this.tx.unsubscribeAsync(); + } catch { + // BLE manager may already be torn down on shutdown — ignore + } + } + + 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..8374bb18 --- /dev/null +++ b/src/device/transport/bleObserver.ts @@ -0,0 +1,98 @@ +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'; +import { asyncHandler } from '../../util/async.js'; + +export type BleDeviceInfo = DeviceInfo & { + type: 'ble'; + peripheral: Peripheral; +}; + +export default class BleObserver +{ + private static readonly MIN_RSSI = -70; + private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; + + private readonly deviceManager: DeviceManager; + + private readonly logger: Logger; + + private isScanning = false; + + 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)); + + 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(); + } + + 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) { + // 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 = { + type: 'ble', + id: DeviceId.create(peripheral.id), + peripheral, + }; + + this.deviceManager.announceDetectedDevice(deviceInfo); + } + + 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(); + } + } +} 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 e2c4168f..a3d78f69 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -1,8 +1,15 @@ 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 { usb } from 'usb'; import { logError } from '../../util/error.js'; +import { DeviceId } from '../deviceId.js'; + +export type SerialDeviceInfo = DeviceInfo & { + type: 'serial'; + portInfo: PortInfo; +}; export default class SerialPortObserver { @@ -78,12 +85,13 @@ export default class SerialPortObserver if (!this.managedDevices.has(portInfo.serialNumber)) { const deviceInfo: SerialDeviceInfo = { - id: portInfo.serialNumber, + type: 'serial', + id: DeviceId.create(portInfo.serialNumber), portInfo }; 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); } @@ -94,7 +102,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) { @@ -102,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/device/types.d.ts b/src/device/types.d.ts deleted file mode 100644 index e69de29b..00000000 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/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/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/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/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/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 2e8c4d8d..39616b81 100644 --- a/src/serial/synchronousSerialPort.ts +++ b/src/serial/synchronousSerialPort.ts @@ -124,7 +124,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..9e0895e8 100644 --- a/src/serialization/classToPlainSerializer.ts +++ b/src/serialization/classToPlainSerializer.ts @@ -9,7 +9,8 @@ export default class ClassToPlainSerializer this.options = options; } - public transform(object: T, typeOptions: TypeOptions|null = null): V + public transform>(object: object, typeOptions?: TypeOptions | null): TOut; + public transform(object: object, typeOptions: TypeOptions | null = null): Record { 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/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/serviceMap.ts b/src/serviceMap.ts index 28852562..e27d557c 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -11,7 +11,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'; @@ -44,6 +44,12 @@ 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'; +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 = { @@ -61,16 +67,18 @@ 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.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, 'device.uniqueNameGenerator': DeviceNameGenerator, 'device.updater': DeviceUpdaterInterface, 'device.observer.serial': SerialPortObserver, + 'device.observer.ble': BleObserver, /* factoryServiceProvider */ 'factory.uuid': UuidFactory, @@ -85,7 +93,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 90418c36..ffe303ac 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -36,6 +36,10 @@ 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'; +import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; +import { DeviceId } from '../device/deviceId.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -70,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') ); }); @@ -87,7 +91,6 @@ export default class DeviceServiceProvider implements ServiceProvider new SlvCtrlPlusDeviceFactory( - container.get('factory.uuid'), container.get('factory.date'), container.get('factory.eventEmitter'), container.get('settings'), @@ -96,7 +99,6 @@ export default class DeviceServiceProvider implements ServiceProvider new ButtplugIoDeviceFactory( - container.get('factory.uuid'), container.get('factory.date'), container.get('factory.eventEmitter'), container.get('settings'), @@ -104,7 +106,6 @@ export default class DeviceServiceProvider implements ServiceProvider new Zc95DeviceFactory( - container.get('factory.uuid'), container.get('factory.date'), container.get('factory.eventEmitter'), container.get('settings'), @@ -113,7 +114,6 @@ export default class DeviceServiceProvider implements ServiceProvider new Estim2bDeviceFactory( - container.get('factory.uuid'), container.get('factory.date'), container.get('factory.eventEmitter'), container.get('settings'), @@ -171,27 +171,30 @@ export default class DeviceServiceProvider implements ServiceProvider { return new DeviceProviderManager( - container.get('device.manager'), - new Map([ + 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'), @@ -222,11 +225,28 @@ export default class DeviceServiceProvider implements ServiceProvider { + return new GenericDeviceProviderFactory( + AiroticDeviceProvider, + container.get('device.manager'), + container.get('settings'), + 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') + ); + }); } } diff --git a/src/serviceProvider/factoryServiceProvider.ts b/src/serviceProvider/factoryServiceProvider.ts index 79d01ee3..838b2d14 100644 --- a/src/serviceProvider/factoryServiceProvider.ts +++ b/src/serviceProvider/factoryServiceProvider.ts @@ -8,7 +8,7 @@ import EventEmitterFactory from '../factory/eventEmitterFactory.js'; export default class FactoryServiceProvider 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/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index 08f64ae3..836e97c1 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 { @@ -18,10 +17,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 66b2f21c..bbf224b3 100644 --- a/src/settings/knownDevice.ts +++ b/src/settings/knownDevice.ts @@ -1,14 +1,12 @@ import { Exclude, Expose } 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; + private readonly _id: DeviceId; @Expose({ name: 'name' }) private readonly _name: string; @@ -23,24 +21,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..b10714fd 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -2,6 +2,39 @@ 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 { 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 @@ -39,19 +72,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); } public addKnownDevice(knownDevice: KnownDevice): void { - this.knownDevices.set(knownDevice.serialNo, knownDevice); + this.knownDevices.set(knownDevice.id, knownDevice); } public addDeviceSource(deviceSource: DeviceSource): void diff --git a/src/settings/settingsManager.ts b/src/settings/settingsManager.ts index efb114d6..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 ) { @@ -58,10 +59,9 @@ export default class SettingsManager this.settings = SettingsManager.getDefaultSettings(); this.save(); } else { - const plainJsonSettings = JSON.parse(fs.readFileSync(this.settingsFilePath, 'utf8')) as JsonObject; - const valid = this.settingsSchemaValidator.validate(plainJsonSettings); + const plainJsonSettings: JsonObject = JSON.parse(fs.readFileSync(this.settingsFilePath, 'utf8')); - 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); @@ -100,9 +100,10 @@ export default class SettingsManager } try { + const normalized = this.classToPlainSerializer.transform(this.settings); 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/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/socket/types.ts b/src/socket/types.ts index eabc32bc..4b81dab6 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -1,13 +1,14 @@ 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'; 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; @@ -17,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/src/types.d.ts b/src/types.d.ts index 9b051fe7..15fadf36 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -9,3 +9,7 @@ export type JsonValue = | JsonObject; export type AllOrNone = (T & {}) | Partial>; + +export type DropFirst = T extends [unknown, ...infer R] ? R : []; + +export type Nullable = T | null; diff --git a/src/util/async.ts b/src/util/async.ts index 030f80e5..f9985b7f 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -8,9 +8,9 @@ export class IntervalTimeoutError extends Error { } 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); @@ -24,67 +24,83 @@ 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) { - const timeoutMs = options.timeoutMs; - promises.push(new Promise((_, reject) => - timeoutHandle = setTimeout(() => { - reject(new IntervalTimeoutError(timeoutMs)); - }, timeoutMs)) - ); - } + if (undefined !== options.timeoutMs) { + const timeoutMs = options.timeoutMs; + promises.push(new Promise((_, reject) => + timeoutHandle = setTimeout(() => { + reject(new IntervalTimeoutError(timeoutMs)); + }, timeoutMs)) + ); + } + + 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); + } + } + }; - try { - await Promise.race(promises); - } catch (err) { - if (options.onError) { - options.onError(err); - } else { - throw err; - } - } finally { - clearTimeout(timeoutHandle); - - 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 => { + let timeoutHandle: ReturnType; + + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timeoutHandle); + }); +}; \ No newline at end of file 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/src/util/createMapTransformFn.ts b/src/util/createMapTransformFn.ts index f8624a3f..c6bedaaa 100644 --- a/src/util/createMapTransformFn.ts +++ b/src/util/createMapTransformFn.ts @@ -8,19 +8,21 @@ 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); + return [k, transformedValue]; }) as [string, T][]; return new Map(transformedEntries); } diff --git a/src/util/expressUtils.ts b/src/util/expressUtils.ts index bf556c9f..927f89d1 100644 --- a/src/util/expressUtils.ts +++ b/src/util/expressUtils.ts @@ -1,14 +1,18 @@ import type { Request, Response } from 'express'; import type { Container } 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: Container, - controllerName: ControllerKey + controllerName: K ): (req: Request, res: Response) => void | Promise => { - return (req: Request, res: Response) => container.get(controllerName).execute(req, res); + return (req: Request, res: Response) => { + const controller: ControllerInterface = container.get(controllerName); + return controller.execute(req, res); + } } 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 787102f4..8ea7effc 100644 --- a/src/util/objects.ts +++ b/src/util/objects.ts @@ -1,6 +1,7 @@ type StringKey = Extract; export const getTypedKeys = (obj: T): (StringKey)[] => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return Object.keys(obj) as StringKey[]; } 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/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/devices/airoticDevice.spec.ts b/tests/integration/devices/airoticDevice.spec.ts new file mode 100644 index 00000000..120cda11 --- /dev/null +++ b/tests/integration/devices/airoticDevice.spec.ts @@ -0,0 +1,256 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import AiroticDeviceProvider from '../../../src/device/protocol/airotic/airoticDeviceProvider.js'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { AiroticDeviceSimulator, mockNoble } from '../helpers/airoticDeviceSimulator.js'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; + +// --------------------------------------------------------------------------- +// Mock the noble singleton before any app code imports it. +// All modules that `import noble from '@stoprocent/noble'` will receive +// mockNoble, which is a controllable EventEmitter with stub scanning methods. +// --------------------------------------------------------------------------- +vi.mock('@stoprocent/noble', () => ({ 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/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..43045fe2 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,9 +31,9 @@ class StubDevice extends Device { ); } - public setAttribute< - K extends keyof DeviceAttributes & string, - V extends ExtractAttributeValue + public async setAttribute< + K extends AttributeKeyOf, + V extends AttributeValueOf >(attributeName: K, value: V): Promise { this.setAttributeCalls.push([attributeName, value]); return Promise.resolve(value); @@ -72,7 +73,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 +89,7 @@ function dispatchAndCollect( resolve(logs); } }); - runtime.runForEvent(eventType, device); + runtime.runForEvent(event ?? { type: DeviceManagerEvent.deviceConnected, device, args: [] }); }); } @@ -99,16 +100,16 @@ 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(); - 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()); @@ -116,21 +117,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 +140,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'); }); `); @@ -193,63 +180,92 @@ 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.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}'); }); `); 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); }); // ----------------------------------------------------------------------- - // 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 +275,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 +289,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,20 +310,20 @@ 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'); + onEvent('deviceConnected', async (device) => { + 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 () => { 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,8 +336,8 @@ 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'); + onEvent('deviceConnected', async (device) => { + const d = devices.getById('${deviceB.getDeviceId}'); await d.setAttribute('label', 'from-script'); console.log('${TEST_END_MARKER}'); }); @@ -337,7 +353,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)); @@ -347,8 +363,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); }); // ----------------------------------------------------------------------- @@ -357,8 +373,8 @@ 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'); + onEvent('deviceConnected', async (device) => { + 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}'); @@ -371,7 +387,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,8 +405,8 @@ 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'); + onEvent('deviceConnected', async (device) => { + await devices.getById('${deviceB.getDeviceId}').setAttribute('label', 'updated'); console.log('${TEST_END_MARKER}'); }); `); @@ -401,7 +417,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 +434,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,24 +448,25 @@ 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(['device-a', 'device-b']); + expect(received).toEqual([deviceA.getDeviceId, deviceB.getDeviceId]); }); // ----------------------------------------------------------------------- // 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); }); }); diff --git a/tests/unit/device/bleDevice.spec.ts b/tests/unit/device/bleDevice.spec.ts new file mode 100644 index 00000000..b8403552 --- /dev/null +++ b/tests/unit/device/bleDevice.spec.ts @@ -0,0 +1,214 @@ +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'; +import BleUartDeviceTransport from '../../../src/device/transport/bleDeviceTransport.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, V extends AttributeValueOf>( + _attributeName: K, + _value: V, + ): Promise { + throw new Error('Not implemented'); + } +} + +describe('BleDevice', () => { + let mockPeripheral: ReturnType>; + let mockLogger: ReturnType>; + let mockTransport: 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(); + mockTransport = 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('closes the transport before disconnecting', async () => { + const device = createDevice(); + const callOrder: string[] = []; + mockPeripheral.disconnectAsync.mockImplementation(async () => { callOrder.push('peripheral'); }); + + await device.close(); + + expect(callOrder).toStrictEqual(['peripheral']); + }); + + 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 3f8ac6ef..3e4c0e22 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -1,21 +1,24 @@ -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', () => { 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); - 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); @@ -29,16 +32,19 @@ describe('deviceManager', () => { expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(1); expect(mockedDeviceManagerEventEmitter.emit).toBeCalledWith(DeviceManagerEvent.deviceConnected, device); + expect(mockedLogger.child).toBeCalledWith({ name: DeviceManager.name }); }); 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(); + const mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedLogger); @@ -50,6 +56,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); }); @@ -57,10 +64,12 @@ 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(); + const mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedLogger); @@ -74,5 +83,208 @@ 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 }); + }); + + 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 = { type: 'test', 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 = { type: 'test', 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 = { type: 'test', 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 = { type: 'test', 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 = { type: 'test', 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..043bd704 --- /dev/null +++ b/tests/unit/device/protocol/airotic/airoticDevice.spec.ts @@ -0,0 +1,511 @@ +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 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'; +import { DeviceEvent } from '../../../../../src/device/device.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 mockTransport: 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.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), + }; + } + + function createDevice(): AiroticDevice { + return new AiroticDevice( + DeviceId.create('airotic-device'), + 'Airotic', + 'airotic-provider', + mockPeripheral, + mockTransport, + mockHandler, + new Date(), + true, + createAttributes(), + {}, + new EventEmitter(), + mockLogger, + ); + } + + beforeEach(() => { + vi.useFakeTimers(); + + mockPeripheral = mock(); + mockTransport = 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); + mockTransport.close.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'); + }); + + it('persists the value on the attribute', async () => { + const device = createDevice(); + + await device.setAttribute('restColor', '10,20,30'); + + expect((await device.getAttribute('restColor'))?.value).toStrictEqual('10,20,30'); + }); + }); + + 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'); + }); + + it('persists the value on the attribute', async () => { + const device = createDevice(); + + await device.setAttribute('breathInColor', '5,10,15'); + + expect((await device.getAttribute('breathInColor'))?.value).toStrictEqual('5,10,15'); + }); + }); + + 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)); + }); + }); + + 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)); + }); + + }); + + 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 new file mode 100644 index 00000000..c2f050c7 --- /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 7', 'utf8'); + + const result = protocol.decode(input); + + expectToBeSuccessfulDecodeResult(result); + expect(result.message).toStrictEqual('Hello I am bottle 7'); + }); + }); + + 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 7', 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 7', 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/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index 5ab9acd9..bf73271c 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 @@ -9,14 +10,15 @@ 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 {EventEmitter} from 'events'; +import {DeviceId} from '../../../../../src/device/deviceId.js'; describe('ButtplugIoDevice', () => { function createDevice(buttplugDeviceMock: ButtplugClientDevice, attrs: ButtplugIoDeviceAttributes): ButtplugIoDevice { return new ButtplugIoDevice( - 'device-id', + DeviceId.create('device-id'), 'device name', 'device model', 'buttplugIo', @@ -33,7 +35,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)); @@ -46,7 +48,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); @@ -62,7 +64,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 () => { @@ -70,7 +72,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); - const rangeAttrName = 'range-2' as ButtplugIoDeviceAttributeKey; + const rangeAttrName: ButtplugIoDeviceAttributeKey = 'Vibrate-2'; const rangeAttr = IntRangeDeviceAttribute.create( rangeAttrName, undefined, @@ -96,9 +98,135 @@ describe('ButtplugIoDevice', () => { // Assert expect(buttplugDeviceMock.scalar).toHaveBeenCalledTimes(1); expect(buttplugDeviceMock.scalar).toHaveBeenCalledWith({ - ActuatorType: 'range', + ActuatorType: 'Vibrate', Index: 2, 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..33cfbe9e --- /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 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'; +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: DeviceBidirectionalTransport, + ): 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/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/genericSlvCtrlPlusDevice.spec.ts b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts index c762fa12..655e5d5f 100644 --- a/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts +++ b/tests/unit/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.spec.ts @@ -6,20 +6,21 @@ 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 DeviceBidirectionalTransport from '../../../../../src/device/transport/deviceBidirectionalTransport.js'; import { matchStrictlyEqual } from '../../../helper/matchers.js'; -import {EventEmitter} from 'events'; import Logger from '../../../../../src/logging/Logger.js'; +import EventEmitter from 'events'; +import { DeviceId } from '../../../../../src/device/deviceId.js'; describe('GenericSlvCtrlPlusDevice', () => { function createDevice( attrs: SlvCtrlPlusDeviceAttributes, protocol: SlvCtrlProtocol, - transport: DeviceTransport, + transport: DeviceBidirectionalTransport, ): 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; @@ -27,7 +28,7 @@ describe('GenericSlvCtrlPlusDevice', () => { return new GenericSlvCtrlPlusDevice( fwVersion, deviceUuid, deviceName, model, provider, new Date(), protocol, transport, protocolVersion, attrs, - new EventEmitter(), mock() + mock(), mock(), ); } @@ -54,7 +55,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const attrName = 'bool'; const device = createDevice({}, mockProtocol, mockTransport); @@ -73,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}`; @@ -117,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)) @@ -145,7 +146,7 @@ describe('GenericSlvCtrlPlusDevice', () => { // Arrange const mockProtocol = mock(); - const mockTransport = mock(); + const mockTransport = mock(); const attrName = 'bool'; const device = createDevice({ @@ -161,4 +162,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 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/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/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts new file mode 100644 index 00000000..778ee574 --- /dev/null +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -0,0 +1,761 @@ +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 DeviceBidirectionalTransport from '../../../../../src/device/transport/deviceBidirectionalTransport.js'; +import MessageResponseHandler from '../../../../../src/device/protocol/messageResponseHandler.js'; +import Zc95MessageFactory, { + AckMsgResponse, + PatternDetailsMsgResponse, + PowerStatusMsgResponse, +} from '../../../../../src/device/protocol/zc95/zc95MessageFactory.js'; +import IntRangeDeviceAttribute 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 { + return mockTransport.onReceive.mock.calls[0][0]; + } + + 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', Int.from(5)) + ).rejects.toThrow("Attribute with name 'powerChannel1' does not exist for this device"); + }); + + 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'); + expect(patternAttr).toBeDefined(); + expect(patternAttr).toBeInstanceOf(IntRangeDeviceAttribute); + expect(patternAttr?.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'); + 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 () => { + 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'); + expect(patternAttr).toBeDefined(); + expect(patternAttr).toBeInstanceOf(ListDeviceAttribute); + expect(patternAttr?.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')).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', Int.from(75)); + + expect(mockMsgFactory.createPatternMinMaxChange).toHaveBeenCalledWith(5, Int.from(75)); + const attr = await device.getAttribute('patternAttribute5'); + 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', 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', Int.from(1)); + + expect(mockMsgFactory.createPatternMultiChoiceChange).toHaveBeenCalledWith(7, Int.from(1)); + const attr = await device.getAttribute('patternAttribute7'); + 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', 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?.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?.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?.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(); + }); + }); +}); 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' }); + }); + }); +}); diff --git a/tests/unit/device/testDevice.ts b/tests/unit/device/testDevice.ts index 30b782b3..eebed15f 100644 --- a/tests/unit/device/testDevice.ts +++ b/tests/unit/device/testDevice.ts @@ -1,10 +1,11 @@ import { EventEmitter } from "events"; -import Device, {ExtractAttributeValue, DeviceAttributes} 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 { public constructor( - deviceId: string, + deviceId: DeviceId, deviceName: string, connectedSince: Date, controllable: boolean, @@ -13,14 +14,14 @@ 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, + V extends AttributeValueOf + >(_attributeName: K, _value: V): Promise { throw new Error("Method not implemented."); } } 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 fa6adcbf..712baae1 100644 --- a/tests/unit/device/testDeviceProvider.ts +++ b/tests/unit/device/testDeviceProvider.ts @@ -2,9 +2,8 @@ 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"; -import Device from "../../../src/device/device.js"; -export default class TestDeviceProvider extends DeviceProvider +export default class TestDeviceProvider extends DeviceProvider { public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts new file mode 100644 index 00000000..cf45f5b7 --- /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({ type: 'ble', 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..c3e27676 --- /dev/null +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -0,0 +1,186 @@ +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.start(); + + 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.start(); + + 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.start(); + + 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.start(); + + 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.start(); + + 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.start(); + 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.start(); + await observer.discoverSerialDevices(); // manually trigger a discovery run + + 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.start(); + await observer.discoverSerialDevices(); // manually trigger a discovery run + await observer.discoverSerialDevices(); // manually trigger a discovery run + + 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.start()).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.start(); + + 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') }), + ); + }); + }); +}); 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 7c052e66..e044e72f 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -1,11 +1,11 @@ -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"], testTimeout: 10000, - hookTimeout: 10000, }, }); 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"], + }, +});