diff --git a/.env.example b/.env.example index d47b3ac6..e60223a4 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,6 @@ PORT=1337 LOG_LEVEL=debug ALLOWED_ORIGINS= APP_VERSION=dev -# SSL_KEY=/path/to/server.key -# SSL_CERT=/path/to/server.crt +# SSL_KEY_FILE=/path/to/server.key +# SSL_CERT_FILE=/path/to/server.crt # HTTPS_PORT=1338 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e3b4baf..b7f3196a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,20 +35,23 @@ jobs: - name: Setup Virtual ALSA Device uses: ./.github/actions/setup-virtual-alsa - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} - name: Install modules - run: npm install + run: npm ci - name: Linting run: | npm run lint - name: Typecheck run: | npm run typecheck - - name: Coverage + - name: Unit tests with coverage run: | - npm run coverage + npm run test:coverage + - name: Integration tests + run: | + npm run test:integration # Summary job that all matrix jobs must pass # Used as the required status check in branch protection diff --git a/eslint.config.ts b/eslint.config.ts index 99822f31..fe61030f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,6 +12,7 @@ export default [ 'coverage', 'tests', 'vitest.config.ts', + 'vitest.config.integration.ts', 'eslint.config.ts', ], }, diff --git a/package-lock.json b/package-lock.json index 5aceaf1f..8ffbbe60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,10 @@ "license": "LGPL-3.0-only", "dependencies": { "@sinclair/typebox": "^0.34.47", - "@timesplinter/pimple": "^2.0.0", + "@timesplinter/pimple": "^2.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "buttplug": "^3.2.1", + "buttplug": "^3.2.2", "class-transformer": "^0.5.1", "cors": "^2.8.5", "dotenv": "^17.2.0", @@ -30,6 +30,7 @@ "sucrase": "^3.35.1", "type-detect": "^4.1.0", "unique-names-generator": "^4.7.1", + "usb": "^3.0.0", "uuid": "^14.0.0" }, "devDependencies": { @@ -38,9 +39,12 @@ "@babel/plugin-proposal-decorators": "^7.22.7", "@babel/preset-env": "^7.14.4", "@babel/preset-typescript": "^7.22.5", + "@serialport/binding-mock": "^10.2.2", "@types/cors": "^2.8.10", "@types/express": "^5.0.6", "@types/node": "^24.1.0", + "@types/supertest": "^7.2.0", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -50,10 +54,13 @@ "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^10.3.0", + "socket.io-client": "^4.8.3", + "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", "vitest": "^4.0.16", - "vitest-mock-extended": "^3.1.0" + "vitest-mock-extended": "^3.1.0", + "ws": "^8.21.0" }, "engines": { "node": ">=24.0.0 <25.0.0" @@ -1876,6 +1883,179 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@node-usb/usb-darwin-arm64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-darwin-arm64/-/usb-darwin-arm64-3.0.0.tgz", + "integrity": "sha512-wBnK74ToiFpH8QGsOSkxjx2BllHIaeW57AUIrAEd1ZWdulbR7l63e07efUSi+iQDf7Qd8HLkD1DnA4w92lagcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-darwin-x64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-darwin-x64/-/usb-darwin-x64-3.0.0.tgz", + "integrity": "sha512-myCWHDMSO7Xo6bSmCprTAuIj/KBD+z2YpKVnDCv65ZB5vF83gqz+7Qlnbqgr92IarAjPLldkyM5dgpHxGF9l5A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-arm-gnueabihf": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm-gnueabihf/-/usb-linux-arm-gnueabihf-3.0.0.tgz", + "integrity": "sha512-EOQOP7QQf92Nr37jaBlL7W4pkB+9YCVxI/l1PZ19KZr/kqBIArdVw4DbkADmJmLwYl4yIeDsJLuQ2O9xLbYcvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-arm64-gnu": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm64-gnu/-/usb-linux-arm64-gnu-3.0.0.tgz", + "integrity": "sha512-H6TLWmgeK0QSDUUCR1KRGQiNWFnSsv9Ec2Bnet8LQJrRtdb0np92oObgzn7X7nz4rpcuahfxH8H2xnLQatZnWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-arm64-musl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm64-musl/-/usb-linux-arm64-musl-3.0.0.tgz", + "integrity": "sha512-d2ROSTlGh0axwSVw3cGXlUEzCW8vJsJOI6T/82xsB/mO+Wy8IC6eRaVzIyEx+hrBiW/TNVI8d9QrIHWG8I9/5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-x64-gnu": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-x64-gnu/-/usb-linux-x64-gnu-3.0.0.tgz", + "integrity": "sha512-r3I6kGKiIm6J5qpdte4aHvMXXizLPonwq5f2ZGZGBY9VJUrUVjV5g1wW5spPZoq5E+R1V+metup1zMWd1Topew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-x64-musl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-x64-musl/-/usb-linux-x64-musl-3.0.0.tgz", + "integrity": "sha512-Qtzms0RQ4XXsuVwiNSDxXsuubfbzVELB4WH4MyP1ABigibA+yslMLHJLZbZyZA1NpOP32Pikmz6C7o9Ho6Z2LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-win32-arm64-msvc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-arm64-msvc/-/usb-win32-arm64-msvc-3.0.0.tgz", + "integrity": "sha512-w4WfwMY07MPHF+393sCUTLl8cZMqyUlEvsxVZjDefXe3LsC/EKhxLWYqVOFszlhYGDpe5YLs2VjFLi5miq0ZjQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-win32-ia32-msvc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-ia32-msvc/-/usb-win32-ia32-msvc-3.0.0.tgz", + "integrity": "sha512-2531uD4hB1ccesjyapYB//VDEARnhx8u1oljBDy5O7o7qZh74KjNtlYXBZBAmCjtZfqIEH+L+YaBFVg39VdTUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-win32-x64-msvc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-x64-msvc/-/usb-win32-x64-msvc-3.0.0.tgz", + "integrity": "sha512-8wbRBW1WKzmOWyTJs9n7/rYytYjL+/hvxaW6rlYuHcj0gLDupDJb9l64FhPI8ZZtF19+LVatQeoNtQLoKVRAuQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1886,6 +2066,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "license": "MIT" @@ -1983,9 +2173,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2003,9 +2190,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2023,9 +2207,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2043,9 +2224,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2063,9 +2241,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2083,9 +2258,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2402,7 +2574,9 @@ "license": "MIT" }, "node_modules/@timesplinter/pimple": { - "version": "2.0.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@timesplinter/pimple/-/pimple-2.1.1.tgz", + "integrity": "sha512-Sy0nqk5480ZukdvEfx9mCmKMJ+jCnjiXePO7Flf+4RK5W5bs1qyGdExQJZ6tg6SoCKQekRvpaW6GNGl/Zf3WeA==", "license": "LGPL-3.0-or-later" }, "node_modules/@tybys/wasm-util": { @@ -2444,6 +2618,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "license": "MIT", @@ -2494,6 +2675,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.7", "license": "MIT", @@ -2528,6 +2716,36 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "license": "MIT", @@ -3095,6 +3313,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3132,6 +3357,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "license": "MIT", @@ -3546,6 +3778,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "6.2.1", "dev": true, @@ -3567,6 +3812,16 @@ "dev": true, "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -3609,6 +3864,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js": { "version": "3.47.0", "dev": true, @@ -3763,6 +4025,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -3780,6 +4052,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "17.2.3", "license": "BSD-2-Clause", @@ -3847,6 +4130,20 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "license": "MIT", @@ -4575,6 +4872,64 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "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" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -5691,9 +6046,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5715,9 +6067,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5739,9 +6088,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5763,9 +6109,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5936,6 +6279,29 @@ "node": ">=18.18.0" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "license": "MIT", @@ -7418,6 +7784,22 @@ } } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", @@ -7671,6 +8053,42 @@ "node": ">= 6" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -8110,6 +8528,30 @@ "punycode": "^2.1.0" } }, + "node_modules/usb": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/usb/-/usb-3.0.0.tgz", + "integrity": "sha512-oKHicfd16YtVrh+icdjjTE+CNpAohAjbt8ecanLhA0F9mVjXnGsu1UoV9vZg9sWQ51Lyc5XVI/jFdqXw5IjNFQ==", + "license": "MIT", + "dependencies": { + "@types/w3c-web-usb": "^1.0.14" + }, + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + }, + "optionalDependencies": { + "@node-usb/usb-darwin-arm64": "3.0.0", + "@node-usb/usb-darwin-x64": "3.0.0", + "@node-usb/usb-linux-arm-gnueabihf": "3.0.0", + "@node-usb/usb-linux-arm64-gnu": "3.0.0", + "@node-usb/usb-linux-arm64-musl": "3.0.0", + "@node-usb/usb-linux-x64-gnu": "3.0.0", + "@node-usb/usb-linux-x64-musl": "3.0.0", + "@node-usb/usb-win32-arm64-msvc": "3.0.0", + "@node-usb/usb-win32-ia32-msvc": "3.0.0", + "@node-usb/usb-win32-x64-msvc": "3.0.0" + } + }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", @@ -8501,6 +8943,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, diff --git a/package.json b/package.json index e912776f..05fab126 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "type": "module", "dependencies": { "@sinclair/typebox": "^0.34.47", - "@timesplinter/pimple": "^2.0.0", + "@timesplinter/pimple": "^2.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "buttplug": "^3.2.1", + "buttplug": "^3.2.2", "class-transformer": "^0.5.1", "cors": "^2.8.5", "dotenv": "^17.2.0", @@ -28,6 +28,7 @@ "sucrase": "^3.35.1", "type-detect": "^4.1.0", "unique-names-generator": "^4.7.1", + "usb": "^3.0.0", "uuid": "^14.0.0" }, "devDependencies": { @@ -36,9 +37,12 @@ "@babel/plugin-proposal-decorators": "^7.22.7", "@babel/preset-env": "^7.14.4", "@babel/preset-typescript": "^7.22.5", + "@serialport/binding-mock": "^10.2.2", "@types/cors": "^2.8.10", "@types/express": "^5.0.6", "@types/node": "^24.1.0", + "@types/supertest": "^7.2.0", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -48,10 +52,13 @@ "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^10.3.0", + "socket.io-client": "^4.8.3", + "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", "vitest": "^4.0.16", - "vitest-mock-extended": "^3.1.0" + "vitest-mock-extended": "^3.1.0", + "ws": "^8.21.0" }, "engines": { "node": ">=24.0.0 <25.0.0" @@ -59,8 +66,9 @@ "scripts": { "compile": "tsc && node --enable-source-maps --trace-warnings dist/index.js | pino-pretty", "dev": "nodemon", - "test": "vitest run", - "coverage": "vitest run --coverage", + "test:unit": "vitest run --config vitest.config.ts", + "test:integration": "vitest run --config vitest.config.integration.ts", + "test:coverage": "vitest run --config vitest.config.ts --coverage", "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json", "lint": "eslint -c eslint.config.ts .", "build": "tsc" diff --git a/resources/schemas/settings.schema.json b/resources/schemas/settings.schema.json index 35cdd316..f0a743d2 100644 --- a/resources/schemas/settings.schema.json +++ b/resources/schemas/settings.schema.json @@ -38,4 +38,3 @@ "required": ["knownDevices", "deviceSources"], "additionalProperties": false } - diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..8bf50460 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,272 @@ +import cors, { CorsOptions } from 'cors'; +import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; +import express from 'express'; +import { Container, Pimple } from '@timesplinter/pimple'; +import ControllerServiceProvider from './serviceProvider/controllerServiceProvider.js'; +import RepositoryServiceProvider from './serviceProvider/repositoryServiceProvider.js'; +import SerializationServiceProvider from './serviceProvider/serializationServiceProvider.js'; +import FactoryServiceProvider from './serviceProvider/factoryServiceProvider.js'; +import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; +import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; +import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; +import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; +import { ClientToServerEvents, ServerToClientEvents, WebsocketServer } from './socket/types.js'; +import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; +import Device from './device/device.js'; +import WebSocketEvent from './device/webSocketEvent.js'; +import AutomationEventType from './automation/automationEventType.js'; +import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; +import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; +import ServiceMap from './serviceMap.js'; +import SettingsEventType from './settings/settingsEventType.js'; +import type Settings from './settings/settings.js'; +import { executeController } from './util/expressUtils.js'; +import { DeviceManagerEvent } from './device/deviceManager.js'; +import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; +import { logError } from './util/error.js'; +import { HealthMetricsCollectorEvent } from './health/healthMetricsCollector.js'; +import { SerializedHealthMetrics } from './health/serializedTypes.js'; +import http from 'http' +import https from 'https' +import fs from 'fs' +import BaseError from 'modern-errors'; +import { Server } from 'socket.io'; + +export type SslConfig = { port: number, keyFile: string, certFile: string }; + +export interface AppOptions { + allowedOrigins: string[]; + dataPath: string; +} + +export interface ServeResult { + httpServer: http.Server; + httpsServer?: https.Server; +} + +export interface AppInstance { + websocket: WebsocketServer; + serve: (httpPort: number, sslConfig?: SslConfig) => ServeResult; + shutdown: () => Promise; +} + +const configureRoutes = (app: express.Application, container: Container): void => { + app.get('/devices', executeController(container, 'controller.getDevices')); + app.get('/device/:deviceId', executeController(container, 'controller.getDevice')); + app.patch('/device/:deviceId', executeController(container, 'controller.patchDevice')); + + app.get('/automation/scripts', executeController(container, 'controller.automation.getScripts')); + app.get('/automation/scripts/:fileName', executeController(container, 'controller.automation.getScript')); + + app.post('/automation/scripts/:fileName', executeController(container, 'controller.automation.createScript')); + app.delete('/automation/scripts/:fileName', executeController(container, 'controller.automation.deleteScript')); + + app.get('/automation/log', executeController(container, 'controller.automation.getLog')); + app.post('/automation/run', executeController(container, 'controller.automation.runScript')); + app.get('/automation/stop', executeController(container, 'controller.automation.stopScript')); + app.get('/automation/status', executeController(container, 'controller.automation.statusScript')); + + app.get('/settings', executeController(container, 'controller.settings.get')); + app.put('/settings', executeController(container, 'controller.settings.put')); + + app.get('/health', executeController(container, 'controller.health')); + app.get('/version', executeController(container, 'controller.version')); +} + +const configureWebsocket = (io: WebsocketServer, container: Container): void => { + const deviceManager = container.get('device.manager'); + const scriptRuntime = container.get('automation.scriptRuntime'); + const settingsManager = container.get('settings.manager'); + const serializer = container.get('serializer.classToPlain'); + const logger = container.get('logger.default'); + const healthMetricsCollector = container.get('health.metricsCollector'); + + const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); + + // Health metrics: start background refresh, broadcast over WebSocket on each collection + healthMetricsCollector.start(1000); + healthMetricsCollector.on(HealthMetricsCollectorEvent.collected, (metrics: SerializedHealthMetrics) => { + io.emit(WebSocketEvent.healthMetrics, metrics); + }); + + // Whenever someone connects this gets executed + io.on('connection', socket => { + logger.debug(`Client connected: ${socket.id}`); + + socket.on('disconnect', () => { + logger.debug(`Client disconnected: ${socket.id}`); + }); + + const deviceUpdateHandler = container.get('socket.deviceUpdateHandler'); + + socket.on(WebSocketEvent.deviceUpdateReceived, (data) => deviceUpdateHandler.handle(data)); + }); + + deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { + io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); + }); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { + io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceDisconnected, device); + }); + + deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { + io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceRefreshed, device); + }); + + settingsManager.on(SettingsEventType.changed, (settings: Settings) => { + io.emit(SettingsEventType.changed, serializer.transform(settings)); + }); + + // Automation events + scriptRuntime.on(AutomationEventType.consoleLog, (data: string) => io.emit(AutomationEventType.consoleLog, data)); +}; + +const loadDeviceProviders = (container: Container): void => { + const serialPortObserver = container.get('device.observer.serial'); + const logger = container.get('logger.default'); + const settings = container.get('settings'); + const deviceProviderManager = container.get('device.provider.loader'); + + deviceProviderManager.loadFromSettings(settings); + + deviceProviderManager + .startProviders() + .catch(e => logError(logger, `Loading device providers failed`, e)); + + serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e)); +}; + +const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({ + origin: (origin, callback): void => { + if (undefined === origin || allowedOrigins.length === 0) { + return callback(null, true); + } + return callback(null, allowedOrigins.includes(origin)); + }, +}); + +export const createContainer = (dataPath: string): Pimple => (new Pimple()) + .register(new LoggerServiceProvider()) + .register(new HealthServiceProvider()) + .register(new SettingsServiceProvider(dataPath)) + .register(new DeviceServiceProvider()) + .register(new ControllerServiceProvider()) + .register(new SocketServiceProvider()) + .register(new RepositoryServiceProvider(dataPath)) + .register(new SerializationServiceProvider()) + .register(new AutomationServiceProvider(dataPath)) + .register(new FactoryServiceProvider()) + .register(new SchemaValidationServiceProvider()) +; + +const getPortFromServer = (server: http.Server): number => { + const address = server.address(); + if (address === null || address === undefined || typeof address !== 'object') { + throw new Error('Could not obtain server address'); + } + + return address.port; +} + +export const createApp = (container: Container, options: AppOptions): AppInstance => { + const corsOptions = buildCorsOptions(options.allowedOrigins); + const websocketServer: WebsocketServer = new Server(undefined, { + cors: corsOptions, + }); + const app = express(); + + app + .use((req, res, next) => { + // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged + if (req.headers['access-control-request-private-network'] === 'true') { + res.header('Access-Control-Allow-Private-Network', 'true'); + } + + next(); + }) + .use(cors(corsOptions)) + .use(contentTypeMiddleware) + .use(express.json()) + .use(express.text()) + ; + + configureRoutes(app, container); + configureWebsocket(websocketServer, container); + loadDeviceProviders(container); + + + let serveResult: ServeResult | undefined; + let canBeShutDown = false; + + return { + websocket: websocketServer, + serve: (httpPort: number, sslConfig?: SslConfig): ServeResult => { + const logger = container.get('logger.default'); + const httpServer = http.createServer(app); + + websocketServer.attach(httpServer); + + serveResult = { httpServer, httpsServer: undefined }; + + httpServer.listen(httpPort, () => { + logger.info(`SlvCtrl+ server listening on http://localhost:${getPortFromServer(httpServer)}`); + }); + + if (sslConfig !== undefined) { + try { + const key = fs.readFileSync(sslConfig.keyFile); + const cert = fs.readFileSync(sslConfig.certFile); + const httpsServer = https.createServer({ key, cert }, app); + + websocketServer.attach(httpsServer); + + serveResult.httpsServer = httpsServer; + + httpsServer.listen(sslConfig.port, () => { + logger.info(`SlvCtrl+ server listening on https://localhost:${getPortFromServer(httpsServer)} (ssl)`); + }); + } catch (err) { + const baseError = BaseError.normalize(err); + logger.error(`Failed to load SSL certificates: ${baseError.message}`); + logger.warn('HTTPS server will not be started'); + } + } + + canBeShutDown = true; + + return serveResult; + }, + shutdown: async (): Promise => { + if (!canBeShutDown) { + return; + } + + canBeShutDown = false; + + const logger = container.get('logger.default'); + const closeServer = (server: http.Server): Promise => server.listening + ? new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve())) + : Promise.resolve(); + + logger.info('Shutting down...'); + + await container.get('automation.scriptRuntime').stop(); + container.get('device.observer.serial').stop(); + await container.get('device.provider.loader').stopProviders(); + container.get('health.metricsCollector').stop(); + + await websocketServer.close(); + + if (serveResult) { + await closeServer(serveResult.httpServer); + if (serveResult.httpsServer) { + await closeServer(serveResult.httpsServer); + } + } + }, + }; +} diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 23d7b504..561b02f6 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -154,7 +154,7 @@ var __dispatchEvent = function(eventType, deviceId, deviceName) { }; `; -export class ScriptRuntime +export default class ScriptRuntime { private readonly eventEmitter: EventEmitter; @@ -182,7 +182,7 @@ export class ScriptRuntime private eventQueue: (() => Promise)[] = []; - private processingQueue = false; + private processQueuePromise: Promise | null = null; public constructor(deviceRepository: DeviceRepositoryInterface, logPath: string, eventEmitter: EventEmitter, logger: Logger) { this.eventEmitter = eventEmitter; @@ -270,6 +270,9 @@ export class ScriptRuntime this.lifecycleRef = await this.vmContext.global.get('__dispatchLifecycle'); this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`); + this.logWriter.on('error', (err) => { + this.logger.error(`Automation log write error: ${err.message}`); + }); this.runningSince = new Date(); const lifecycleRef = this.lifecycleRef; @@ -296,8 +299,27 @@ export class ScriptRuntime public async stop(): Promise { + if (this.isRunning() === false) { + return; + } + + // Null dispatchRef first so runForEvent() returns early for any events + // arriving during teardown, and queued-but-not-started tasks resolve immediately. + this.dispatchRef = null; + + // Unblock any in-flight event handler so processQueue() can exit its await + if (this.pendingEventDone !== null) { + const done = this.pendingEventDone; + this.pendingEventDone = null; + done('script stopped'); + } this.eventQueue = []; - this.processingQueue = false; + + // Wait for the processQueue coroutine to finish before tearing down the isolate + if (this.processQueuePromise !== null) { + await this.processQueuePromise; + this.processQueuePromise = null; + } const lifecycleRef = this.lifecycleRef; if (lifecycleRef !== null) { @@ -314,7 +336,6 @@ export class ScriptRuntime } } - this.dispatchRef = null; this.lifecycleRef = null; this.pendingEventDone = null; this.pendingLifecycleDone = null; @@ -332,8 +353,9 @@ export class ScriptRuntime this.runningSince = null; if (this.logWriter !== null) { - this.logWriter.close(); + const writer = this.logWriter; this.logWriter = null; + await new Promise(resolve => writer.close(() => resolve())); } this.eventEmitter.emit(AutomationEventType.scriptStopped); @@ -366,15 +388,13 @@ export class ScriptRuntime ); })); - if (!this.processingQueue) { - void this.processQueue(); + if (this.processQueuePromise === null) { + this.processQueuePromise = this.processQueue(); } } private async processQueue(): Promise { - this.processingQueue = true; - while (this.eventQueue.length > 0) { const task = this.eventQueue.shift()!; try { @@ -387,7 +407,7 @@ export class ScriptRuntime } } - this.processingQueue = false; + this.processQueuePromise = null; } public async getLog(maxLines: number): Promise @@ -417,6 +437,10 @@ export class ScriptRuntime this.eventEmitter.on(event, listener); return this; } -} -export default ScriptRuntime; + public off (event: E, listener: ScriptRuntimeEvents[E]): this + { + this.eventEmitter.off(event, listener); + return this; + } +} diff --git a/src/controller/automation/stopScriptController.ts b/src/controller/automation/stopScriptController.ts index 5e88e1c2..76749b86 100644 --- a/src/controller/automation/stopScriptController.ts +++ b/src/controller/automation/stopScriptController.ts @@ -11,9 +11,9 @@ export default class StopScriptController implements ControllerInterface this.scriptRuntime = scriptRuntime; } - public execute(req: Request, res: Response): void + public async execute(req: Request, res: Response): Promise { - this.scriptRuntime.stop(); + await this.scriptRuntime.stop(); res.sendStatus(200); } diff --git a/src/controller/healthController.ts b/src/controller/healthController.ts index f62a5351..20bdfa88 100644 --- a/src/controller/healthController.ts +++ b/src/controller/healthController.ts @@ -11,8 +11,15 @@ export default class HealthController implements ControllerInterface this.healthMetricsCollector = healthMetricsCollector; } - public async execute(req: Request, res: Response): Promise + public execute(_req: Request, res: Response): void { - res.json(await this.healthMetricsCollector.collect()); + const metrics = this.healthMetricsCollector.collect(); + + if (metrics === null) { + res.sendStatus(204); + return; + } + + res.json(metrics); } } diff --git a/src/device/attribute/intRangeDeviceAttribute.ts b/src/device/attribute/intRangeDeviceAttribute.ts index 02433f23..e4fd361e 100644 --- a/src/device/attribute/intRangeDeviceAttribute.ts +++ b/src/device/attribute/intRangeDeviceAttribute.ts @@ -13,7 +13,7 @@ export default class IntRangeDeviceAttribute[] { return Array.from(this.connectedDevices.values()); } @@ -149,6 +149,25 @@ export default class DeviceManager this.eventEmitter.on(event, listener); } + public off( + event: T, + listener: (...args: DeviceManagerEventMap[T]) => void + ): void + { + this.eventEmitter.off(event, listener); + } + + public async reset(): Promise + { + for (const [, device] of this.connectedDevices) { + await device.close(); + } + + for (const [deviceId] of this.detectedDeviceAcquireQueue) { + this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); + } + } + private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void { for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 55e484e7..f14ade94 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -40,6 +40,10 @@ export default class ButtplugIoDevice extends Device this.deviceModel = deviceModel; } + public override get getRefreshInterval(): number { + return 100; + } + protected override async doRefresh(): Promise { for (const sensor of this.buttplugClientDevice.messageAttributes.SensorReadCmd ?? []) { const value = await this.buttplugClientDevice.sensorRead(sensor.Index, sensor.SensorType); diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index c45eacb8..5e9b236c 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -88,14 +88,14 @@ export default class ButtplugIoDeviceFactory // A range is defined by two numbers, if there are more or less, let's fallback // to a normal integer attribute. Not that dramatic for a sensor after all. - if (item.StepRange.length === 2) { + if ('SensorRange' in item && Array.isArray(item.SensorRange) && item.SensorRange.length === 2) { attributes[attrName] = IntRangeDeviceAttribute.createInitialized( `${item.SensorType}-${item.Index}`, item.FeatureDescriptor, DeviceAttributeModifier.readOnly, undefined, - Int.from(item.StepRange[0]), - Int.from(item.StepRange[1]), + Int.from(item.SensorRange[0]), + Int.from(item.SensorRange[1]), Int.from(1), Int.ZERO ); diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index ac92563a..82105bd9 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -8,6 +8,7 @@ import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; import SlvCtrlPlusButtplugWebsocketClientConnector from './slvCtrlPlusButtplugWebsocketClientConnector.js'; import DeviceManager from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; +import { hasProperty } from '../../../util/objects.js'; export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { public static readonly providerName = 'buttplugIoWebsocket'; @@ -78,7 +79,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.discoverButtplugIoDevices() }, 60000); } } catch (e: unknown) { - logError(this.logger, `Could not connect to buttplug.io server (${url})`, (typeof e === 'object' && e !== null && 'message' in e) ? e.message : 'unknown'); + logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); } } diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index 0492b79f..3696db73 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -1,5 +1,6 @@ -import { ReadlineParser, SerialPort } from 'serialport'; -import { PortInfo } from '@serialport/bindings-interface'; +import { ReadlineParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; @@ -37,7 +38,7 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { + protected async connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); const syncPort = new SynchronousSerialPort(portInfo, parser, port, this.logger); const transport = this.transportFactory.create(syncPort, undefined, Buffer.from('\r')); diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts index b6a8f76a..75717f89 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts @@ -39,7 +39,7 @@ export default abstract class SlvCtrlPlusDevice< protected async send(command: SlvCtrlProtocolCommand): Promise { const encodedCommand = this.protocol.encode(command); - const response = await this.transport.sendAndAwaitReceive(encodedCommand); + const response = await this.transport.sendAndAwaitReceive(encodedCommand, SlvCtrlProtocol.transportTimeoutMs); const decodedResponse = this.protocol.decode(response); if ('error' in decodedResponse) { diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts index c2a18867..3c390121 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts @@ -1,5 +1,6 @@ -import { ReadlineParser, ReadyParser, SerialPort } from 'serialport'; -import type { PortInfo } from '@serialport/bindings-interface'; +import { ReadlineParser, ReadyParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; import SlvCtrlPlusDeviceFactory from './slvCtrlPlusDeviceFactory.js'; import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; import EventEmitter from 'events'; @@ -40,7 +41,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide this.deviceTransportFactory = deviceTransportFactory; } - protected async connectSerialDevice(port: SerialPort, portInfo: PortInfo): Promise { + protected async connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); const syncPort = new SynchronousSerialPort(portInfo, parser, port, this.logger); const transport = this.deviceTransportFactory.create(syncPort, undefined, Buffer.from(SlvCtrlProtocol.EOF)); @@ -90,7 +91,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide return { baudRate: 9600 }; } - protected override preparePort(port: SerialPort, portInfo: PortInfo): Promise { + protected override preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { return new Promise((resolve, reject) => { if (portInfo.vendorId !== SlvCtrlPlusSerialDeviceProvider.arduinoVendorId) { // It's NOT an Arduino diff --git a/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts b/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts index 546db33c..55578cf0 100644 --- a/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts @@ -27,10 +27,14 @@ export default class RandomGeneratorVirtualDeviceLogic extends VirtualDeviceLogi return 100; } - public async refreshData( - device: VirtualDevice - ): Promise { - const newNumber = Math.floor(Math.random() * (this.config.max - this.config.min + 1)) + this.config.min; + public async refreshData(device: VirtualDevice): Promise { + const currentNumber = (await device.getAttribute('value'))?.value; + let newNumber: number; + + do { + newNumber = Math.floor(Math.random() * (this.config.max - this.config.min + 1)) + this.config.min; + } while (currentNumber !== undefined && newNumber === currentNumber); + await device.setAttribute('value', Int.from(newNumber)); } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 15c84e1c..4806d93a 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -21,6 +21,8 @@ export default class VirtualDeviceProvider extends DeviceProvider { - // Scan for new virtual devices every 3 seconds this.discoveryInterval ??= setImmediateInterval(asyncHandler( this.discoverVirtualDevices.bind(this), (e: unknown) => this.logger.error('Error while scanning for new virtual devices', e) - ), 3000); + ), this.scanIntervalMs); + } + + public override async stop(): Promise { + if (this.discoveryInterval !== undefined) { + clearInterval(this.discoveryInterval); + this.discoveryInterval = undefined; + } } private async discoverVirtualDevices(): Promise { diff --git a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts index c6e23c78..a3e6a419 100644 --- a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts +++ b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts @@ -6,8 +6,14 @@ import VirtualDeviceFactory from './virtualDeviceFactory.js'; import DeviceManager from '../../deviceManager.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; +type VirtualDeviceProviderConfig = { + scanIntervalMs: number, +} + export default class VirtualDeviceProviderFactory implements DeviceProviderFactory { + private static readonly DEFAULT_SCAN_INTERVAL_MS = 3000; + private readonly deviceManager: DeviceManager; private readonly eventEmitterFactory: EventEmitterFactory; @@ -32,13 +38,18 @@ export default class VirtualDeviceProviderFactory implements DeviceProviderFacto this.logger = logger; } - public create(): VirtualDeviceProvider { + public create(config: VirtualDeviceProviderConfig): VirtualDeviceProvider { + const scanIntervalMs = typeof config.scanIntervalMs === 'number' && config.scanIntervalMs > 0 + ? config.scanIntervalMs + : VirtualDeviceProviderFactory.DEFAULT_SCAN_INTERVAL_MS; + return new VirtualDeviceProvider( this.deviceManager, this.eventEmitterFactory.create(), this.deviceFactory, this.settingsManager, - this.logger + this.logger, + scanIntervalMs, ); } } diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index 2eb30811..8a0077ae 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -1,5 +1,5 @@ -import { SerialPort } from 'serialport'; -import { PortInfo } from '@serialport/bindings-interface'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; @@ -38,7 +38,7 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { + protected async connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise { const serialLogger = this.logger.child({ name: Zc95Device.name }) const parser = port.pipe(new FrameParser({ stx: Zc95Protocol.STX, etx: Zc95Protocol.ETX })); @@ -90,10 +90,10 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { + private async reset(port: SerialPortStream, close: boolean = false): Promise { return new Promise((resolve, reject) => { - port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr) => { - if (writeErr) { + port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr: Error | null | undefined) => { + if (null != writeErr) { reject(writeErr); return; } @@ -101,8 +101,8 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider EOT'); if (close) { - port.close((closeErr) => { - if (closeErr) { + port.close((closeErr: Error | null) => { + if (null != closeErr) { reject(closeErr); return; } diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 3f331388..c49d3e0b 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -25,4 +25,8 @@ export default abstract class DeviceProvider< public async init(): Promise { return Promise.resolve(); } + + public async stop(): Promise { + return Promise.resolve(); + } } diff --git a/src/device/provider/deviceProviderLoader.ts b/src/device/provider/deviceProviderManager.ts similarity index 54% rename from src/device/provider/deviceProviderLoader.ts rename to src/device/provider/deviceProviderManager.ts index 63cf5799..f6f3aa88 100644 --- a/src/device/provider/deviceProviderLoader.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -2,37 +2,32 @@ import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; import DeviceManager from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; -import SerialPortObserver from '../transport/serialPortObserver.js'; +import DeviceProvider from './deviceProvider.js'; +import Device from '../device.js'; -export default class DeviceProviderLoader +export default class DeviceProviderManager { - private settings: Settings; - private factories: Map; private readonly deviceManager: DeviceManager; - private readonly serialPortObserver: SerialPortObserver; - private readonly logger: Logger; + private providers: DeviceProvider[] = []; + public constructor( deviceManager: DeviceManager, - serialPortObserver: SerialPortObserver, - settings: Settings, factories: Map, logger: Logger ) { this.deviceManager = deviceManager; - this.serialPortObserver = serialPortObserver; - this.settings = settings; this.factories = factories; this.logger = logger; } - public async loadFromSettings(): Promise + public loadFromSettings(settings: Settings): void { - const configuredDeviceSources = this.settings.getDeviceSources(); + const configuredDeviceSources = settings.getDeviceSources(); this.logger.debug(`Found ${configuredDeviceSources.size} configured device source(s)`); @@ -46,7 +41,30 @@ export default class DeviceProviderLoader const provider = factory.create(deviceSource.config); + this.providers.push(provider); + } + } + + public async startProviders(): Promise { + for (const provider of this.providers) { await provider.init(); } } + + public async stopProviders(): Promise { + const errors: unknown[] = []; + + for (const provider of this.providers) { + try { + await provider.stop(); + } catch (error: unknown) { + errors.push(error); + this.logger.error('Failed to stop device provider', error); + } + } + + if (errors.length > 0) { + throw new Error(`Failed to stop ${errors.length} device provider(s)`); + } + } } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 629cf615..13e17839 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -1,8 +1,9 @@ import DeviceProvider from './deviceProvider.js'; import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; -import { PortInfo } from '@serialport/bindings-interface'; -import { SerialPort, SerialPortOpenOptions } from 'serialport'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import { SerialPortOpenOptions } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; import SerialPortFactory from '../../factory/serialPortFactory.js'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; @@ -23,18 +24,19 @@ export default abstract class SerialDeviceProvider< { private readonly serialPortFactory: SerialPortFactory; + private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; + protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; - this.deviceManager.on( - DeviceManagerEvent.deviceDetected, - asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ) + this.deviceDetectedListener = asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) ); + + this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { @@ -119,11 +121,15 @@ export default abstract class SerialDeviceProvider< } // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected preparePort(port: SerialPort, portInfo: PortInfo): Promise { + protected preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { return Promise.resolve(); } - protected abstract connectSerialDevice(port: SerialPort, portInfo: PortInfo): Promise; + public override async stop(): Promise { + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + } + + protected abstract connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/serializedTypes.ts b/src/device/serializedTypes.ts new file mode 100644 index 00000000..2c407fda --- /dev/null +++ b/src/device/serializedTypes.ts @@ -0,0 +1,102 @@ +import DeviceState from './deviceState.js'; +import { DeviceAttributeModifier } from './attribute/deviceAttribute.js'; + +type SerializedDeviceAttributeBase = { + name: string; + label: string | undefined; + modifier: DeviceAttributeModifier; + type: string; +} + +export type SerializedIntRangeDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'range'; + value: number | undefined; + min: number; + max: number; + incrementStep: number; + uom: string | undefined; +} + +export type SerializedIntDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'int'; + value: number | undefined; + uom: string | undefined; +} + +export type SerializedFloatDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'float'; + value: number | undefined; + uom: string | undefined; +} + +export type SerializedBoolDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'bool'; + value: boolean | undefined; +} + +export type SerializedStrDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'str'; + value: string | undefined; +} + +export type SerializedListDeviceAttribute = SerializedDeviceAttributeBase & { + type: 'list'; + value: string | number | undefined; + values: { key: string | number; value: string | number }[]; +} + +export type SerializedDeviceAttribute = + | SerializedIntRangeDeviceAttribute + | SerializedIntDeviceAttribute + | SerializedFloatDeviceAttribute + | SerializedBoolDeviceAttribute + | SerializedStrDeviceAttribute + | SerializedListDeviceAttribute; + +type SerializedDeviceBase = { + connectedSince: Date; + deviceId: string; + deviceName: string; + provider: string; + state: DeviceState; + errorInfo: { reason: string; occurredAt: Date } | undefined; + controllable: boolean; + lastRefresh: Date | undefined; + attributes: Record; + config: Record; +} + +export type SerializedSlvCtrlPlusDevice = SerializedDeviceBase & { + type: 'slvCtrlPlus'; + deviceModel: string; + fwVersion: number; + protocolVersion: number; +} + +export type SerializedButtplugIoDevice = SerializedDeviceBase & { + type: 'buttplugIo'; + deviceModel: string; +} + +export type SerializedVirtualDevice = SerializedDeviceBase & { + type: 'virtual'; + deviceModel: string; + fwVersion: string; +} + +export type SerializedZc95Device = SerializedDeviceBase & { + type: 'zc95'; + fwVersion: string; +} + +export type SerializedEStim2bDevice = SerializedDeviceBase & { + type: 'estim2b'; + fwVersion: string; +} + +export type SerializedDevice = + | SerializedSlvCtrlPlusDevice + | SerializedButtplugIoDevice + | SerializedVirtualDevice + | SerializedZc95Device + | SerializedEStim2bDevice; diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 02755ad7..e2c4168f 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -1,7 +1,7 @@ import { SerialPort } from 'serialport'; import Logger from '../../logging/Logger.js'; import DeviceManager, { SerialDeviceInfo } from '../deviceManager.js'; -import { setIntervalAsync } from '../../util/async.js'; +import { usb } from 'usb'; import { logError } from '../../util/error.js'; export default class SerialPortObserver @@ -14,6 +14,12 @@ export default class SerialPortObserver private managedDevices: Map = new Map(); + private onUsbEventRef?: () => void; + + private rescanTimer?: NodeJS.Timeout; + + private discoveryInFlight = false; + public constructor( deviceManager: DeviceManager, logger: Logger @@ -22,19 +28,35 @@ export default class SerialPortObserver this.logger = logger.child({ name: SerialPortObserver.name }); } - public async init(): Promise + public async start(): Promise { - return new Promise((resolve) => { - // Scan for new serial devices every 3 seconds - setIntervalAsync(async () => await this.discoverSerialDevices(), { - intervalMs: 3000, - onError: (e: unknown) => logError(this.logger, 'Error while scanning for new serial devices', e), - }); - resolve(); - }) + await this.discoverSerialDevices(); + + this.onUsbEventRef = (): void => { + this.logger.debug('USB event detected, scanning for serial devices in 1s...'); + + if (this.rescanTimer !== undefined) { + clearTimeout(this.rescanTimer); + } + + this.rescanTimer = setTimeout(() => { + if (this.discoveryInFlight) { + return; + } + this.discoveryInFlight = true; + this.discoverSerialDevices() + .catch(e => logError(this.logger, 'Error while scanning for new serial devices', e)) + .finally(() => { + this.discoveryInFlight = false; + }); + }, 1000); + }; + + usb.addEventListener('connect', this.onUsbEventRef); + usb.addEventListener('disconnect', this.onUsbEventRef); } - private async discoverSerialDevices(): Promise + public async discoverSerialDevices(): Promise { const foundDevices: Map = new Map(); @@ -79,4 +101,17 @@ export default class SerialPortObserver logError(this.logger, 'Could not list serial ports', err); } } + + public stop(): void { + if (this.rescanTimer !== undefined) { + clearTimeout(this.rescanTimer); + this.rescanTimer = undefined; + } + + if (this.onUsbEventRef !== undefined) { + usb.removeEventListener('connect', this.onUsbEventRef); + usb.removeEventListener('disconnect', this.onUsbEventRef); + this.onUsbEventRef = undefined; + } + } } diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..7e9c26ac --- /dev/null +++ b/src/env.ts @@ -0,0 +1,32 @@ +import { Value } from '@sinclair/typebox/value'; +import { Static, Type } from '@sinclair/typebox'; +import os from 'os'; + +const EnvSchema = Type.Object({ + PORT: Type.Number({ default: 1337, minimum: 1, maximum: 65535 }), + HTTPS_PORT: Type.Number({ default: 1338, minimum: 1, maximum: 65535 }), + SSL_CERT_FILE: Type.Optional(Type.String({ minimumLength: 1 })), + SSL_KEY_FILE: Type.Optional(Type.String({ minimumLength: 1 })), + ALLOWED_ORIGINS: Type.Optional(Type.String({ minimumLength: 1 })), + DATA_PATH: Type.String({ default: `${os.homedir()}/.slvctrlplus`, minimumLength: 1 }), +}); + +type Env = Static; + +export const parseEnv = (env: NodeJS.ProcessEnv): Env => { + const converted = Value.Default(EnvSchema, Value.Convert(EnvSchema, { ...env })); + + if (!Value.Check(EnvSchema, converted)) { + const errors = [...Value.Errors(EnvSchema, converted)]; + throw new Error(`Invalid environment variables:\n${errors.map(e => ` ${e.path}: ${e.message}`).join('\n')}`); + } + + const hasCertFile = converted.SSL_CERT_FILE !== undefined; + const hasKeyFile = converted.SSL_KEY_FILE !== undefined; + + if (hasCertFile !== hasKeyFile) { + throw new Error('Invalid environment variables:\n /SSL_CERT_FILE and /SSL_KEY_FILE must be set together'); + } + + return Value.Decode(EnvSchema, converted); +}; diff --git a/src/factory/serialPortFactory.ts b/src/factory/serialPortFactory.ts index d5fd376f..c4aa47ac 100644 --- a/src/factory/serialPortFactory.ts +++ b/src/factory/serialPortFactory.ts @@ -1,9 +1,11 @@ import { SerialPort, SerialPortOpenOptions } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; +import { BindingInterface } from '@serialport/bindings-interface'; export default class SerialPortFactory { - public create(options: SerialPortOpenOptions): SerialPort { + public create(options: SerialPortOpenOptions): SerialPortStream { return new SerialPort(options); } } diff --git a/src/health/healthMetricsCollector.ts b/src/health/healthMetricsCollector.ts index 9ba924c4..aaa4236b 100644 --- a/src/health/healthMetricsCollector.ts +++ b/src/health/healthMetricsCollector.ts @@ -1,53 +1,69 @@ import os from 'os'; import process from 'process'; -import { NetworkStats, OSUtils } from 'node-os-utils'; +import { EventEmitter } from 'events'; +import { OSUtils } from 'node-os-utils'; +import { IntervalAsync, setIntervalAsync } from '../util/async.js'; +import Logger from '../logging/Logger.js'; +import { logError } from '../util/error.js'; +import { SerializedHealthMetrics } from './serializedTypes.js'; -export type HealthMetrics = { - process: { - memoryUsage: NodeJS.MemoryUsage, - }, - system: { - cpu: { - usage: number | null, - average: number | null, - cores: number | null, - model: string | null, - }, - memory: { - totalMemMb: number; - usedMemMb: number; - freeMemMb: number; - usedMemPercentage: number; - freeMemPercentage: number; - } | null, - os: { - name: string, - type: string, - arch: string, - platform: string, - }, - network: { - netstat: NetworkStats[] | null, - }, - ip?: string | null, - hostname: string | null, - uptime: number | null, - }, -}; +export enum HealthMetricsCollectorEvent { + collected = 'healthMetricsCollected', +} export default class HealthMetricsCollector { private readonly osUtils: OSUtils; - public constructor() + private readonly logger: Logger; + + private readonly eventEmitter: EventEmitter; + + private currentMetrics: SerializedHealthMetrics | null = null; + + private intervalHandle: IntervalAsync | null = null; + + public constructor(logger: Logger, eventEmitter: EventEmitter) { + this.logger = logger; + this.eventEmitter = eventEmitter; this.osUtils = new OSUtils({ cacheEnabled: true, cacheTTL: 60_000, }); } - public async collect(): Promise + public start(intervalMs: number): void + { + if (this.intervalHandle !== null) { + return; + } + + this.intervalHandle = setIntervalAsync( + async () => await this.refresh(), + { intervalMs, timeoutMs: intervalMs * 3, onError: (err) => logError(this.logger, `Health metrics refresh failed`, err) }, + ); + } + + public stop(): void + { + this.intervalHandle?.clear(); + this.intervalHandle = null; + this.eventEmitter.removeAllListeners(); + } + + public on(event: HealthMetricsCollectorEvent, listener: (metrics: SerializedHealthMetrics) => void): this + { + this.eventEmitter.on(event, listener); + return this; + } + + public collect(): SerializedHealthMetrics | null + { + return this.currentMetrics; + } + + private async refresh(): Promise { const [cpuUsage, cpuInfo, cpuLoadAvg, memInfo, sysUptime, networkStats, networkInterfaces] = await Promise.all([ this.osUtils.cpu.usage(), @@ -59,7 +75,7 @@ export default class HealthMetricsCollector this.osUtils.network.interfaces(), ]); - return { + const metrics: SerializedHealthMetrics = { process: { memoryUsage: process.memoryUsage(), }, @@ -95,5 +111,8 @@ export default class HealthMetricsCollector uptime: true === sysUptime.success ? Math.floor(sysUptime.data.uptime / 1000) : null, } }; + + this.currentMetrics = metrics; + this.eventEmitter.emit(HealthMetricsCollectorEvent.collected, metrics); } } diff --git a/src/health/serializedTypes.ts b/src/health/serializedTypes.ts new file mode 100644 index 00000000..45f3ed35 --- /dev/null +++ b/src/health/serializedTypes.ts @@ -0,0 +1,34 @@ +import { NetworkStats } from 'node-os-utils'; + +export type SerializedHealthMetrics = { + process: { + memoryUsage: NodeJS.MemoryUsage, + }, + system: { + cpu: { + usage: number | null, + average: number | null, + cores: number | null, + model: string | null, + }, + memory: { + totalMemMb: number; + usedMemMb: number; + freeMemMb: number; + usedMemPercentage: number; + freeMemPercentage: number; + } | null, + os: { + name: string, + type: string, + arch: string, + platform: string, + }, + network: { + netstat: NetworkStats[] | null, + }, + ip?: string | null, + hostname: string | null, + uptime: number | null, + }, +}; diff --git a/src/index.ts b/src/index.ts index 0b86f7f7..8b9defff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,191 +1,38 @@ import 'dotenv/config'; import 'reflect-metadata'; -import cors, { CorsOptions } from 'cors'; -import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; -import express from 'express'; -import { Pimple } from '@timesplinter/pimple'; -import ControllerServiceProvider from './serviceProvider/controllerServiceProvider.js'; -import RepositoryServiceProvider from './serviceProvider/repositoryServiceProvider.js'; -import SerializationServiceProvider from './serviceProvider/serializationServiceProvider.js'; -import FactoryServiceProvider from './serviceProvider/factoryServiceProvider.js'; -import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; -import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; -import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; -import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; -import { DeviceUpdateData } from './socket/types.js'; -import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; -import Device from './device/device.js'; -import WebSocketEvent from './device/webSocketEvent.js'; -import ServerServiceProvider, { SslConfig } from './serviceProvider/serverServiceProvider.js'; -import AutomationEventType from './automation/automationEventType.js'; -import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; -import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; -import ServiceMap from './serviceMap.js'; -import SettingsEventType from './settings/settingsEventType.js'; -import type Settings from './settings/settings.js'; -import { executeController } from './util/expressUtils.js'; -import { DeviceManagerEvent } from './device/deviceManager.js'; +import { createApp, createContainer, SslConfig } from './app.js'; +import { parseEnv } from './env.js'; import { logError } from './util/error.js'; -import { setIntervalAsync } from './util/async.js'; -import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; -const APP_HTTP_PORT = process.env.PORT ?? '1337'; -const APP_HTTPS_PORT = process.env.HTTPS_PORT ?? '1338'; -const ALLOWED_ORIGINS = undefined !== process.env.ALLOWED_ORIGINS && null !== process.env.ALLOWED_ORIGINS.length - ? process.env.ALLOWED_ORIGINS.split(',') +const env = parseEnv(process.env); + +const allowedOrigins = undefined !== env.ALLOWED_ORIGINS && env.ALLOWED_ORIGINS.length > 0 + ? env.ALLOWED_ORIGINS.split(',') .map(origin => origin.trim()) .filter(origin => origin.length > 0) : []; -const SSL_KEY_FILE = process.env.SSL_KEY; -const SSL_CERT_FILE = process.env.SSL_CERT; - -const sslConfig: SslConfig | undefined = SSL_KEY_FILE !== undefined && SSL_CERT_FILE !== undefined - ? { keyFile: SSL_KEY_FILE, certFile: SSL_CERT_FILE } +const sslConfig: SslConfig | undefined = env.SSL_KEY_FILE !== undefined && env.SSL_CERT_FILE !== undefined + ? { port: env.HTTPS_PORT, keyFile: env.SSL_KEY_FILE, certFile: env.SSL_CERT_FILE } : undefined; -const corsOptions: CorsOptions = { - origin: (origin, callback) => { - if (undefined === origin || ALLOWED_ORIGINS.length === 0) { - return callback(null, true); - } - - return callback(null, ALLOWED_ORIGINS.includes(origin)); - }, -}; - -const app = express(); -const container = new Pimple(); - -container - .register(new LoggerServiceProvider()) - .register(new HealthServiceProvider()) - .register(new ServerServiceProvider(app, corsOptions, sslConfig)) - .register(new SettingsServiceProvider()) - .register(new DeviceServiceProvider()) - .register(new ControllerServiceProvider()) - .register(new SocketServiceProvider()) - .register(new RepositoryServiceProvider()) - .register(new SerializationServiceProvider()) - .register(new AutomationServiceProvider()) - .register(new FactoryServiceProvider()) - .register(new SchemaValidationServiceProvider()) -; +const appOptions = { allowedOrigins, dataPath: env.DATA_PATH }; +const container = createContainer(env.DATA_PATH); +const app = createApp(container, appOptions); const logger = container.get('logger.default'); -const io = container.get('server.websocket'); -const deviceManager = container.get('device.manager'); -const serialPortObserver = container.get('device.observer.serial'); -const settingsManager = container.get('settings.manager'); -const scriptRuntime = container.get('automation.scriptRuntime'); - -container.get('device.provider.loader') - .loadFromSettings() - .catch(e => logError(logger, `Loading device providers failed`, e)); - -// Middlewares -app - .use((req, res, next) => { - // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged - if (req.headers['access-control-request-private-network'] === 'true') { - res.header('Access-Control-Allow-Private-Network', 'true'); - } - - next(); - }) - .use(cors(corsOptions)) - .use(contentTypeMiddleware) - .use(express.json()) - .use(express.text()) -; - -// Routes -app.get('/devices', executeController(container, 'controller.getDevices')); -app.get('/device/:deviceId', executeController(container, 'controller.getDevice')); -app.patch('/device/:deviceId', executeController(container, 'controller.patchDevice')); - -app.get('/automation/scripts', executeController(container, 'controller.automation.getScripts')); -app.get('/automation/scripts/:fileName', executeController(container, 'controller.automation.getScript')); - -app.post('/automation/scripts/:fileName', executeController(container, 'controller.automation.createScript')); -app.delete('/automation/scripts/:fileName', executeController(container, 'controller.automation.deleteScript')); - -app.get('/automation/log', executeController(container, 'controller.automation.getLog')); -app.post('/automation/run', executeController(container, 'controller.automation.runScript')); -app.get('/automation/stop', executeController(container, 'controller.automation.stopScript')); -app.get('/automation/status', executeController(container, 'controller.automation.statusScript')); - -app.get('/settings', executeController(container, 'controller.settings.get')); -app.put('/settings', executeController(container, 'controller.settings.put')); - -app.get('/health', executeController(container, 'controller.health')); -app.get('/version', executeController(container, 'controller.version')); - -// Whenever someone connects this gets executed -io.on('connection', socket => { - logger.debug(`Client connected: ${socket.id}`); - - socket.on('disconnect', () => { - logger.debug(`Client disconnected: ${socket.id}`); - }); - - const deviceUpdateHandler = container.get('socket.deviceUpdateHandler'); - - socket.on(WebSocketEvent.deviceUpdateReceived, (data) => deviceUpdateHandler.handle(data as DeviceUpdateData)); -}); -const serializer = container.get('serializer.classToPlain'); - -const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); - -void serialPortObserver.init(); - -deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { - io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); -}); - -deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { - io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceDisconnected, device); -}); - -deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { - io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceRefreshed, device); -}); - -settingsManager.on(SettingsEventType.changed, (settings: Settings) => { - io.emit(SettingsEventType.changed, serializer.transform(settings)); -}); - -// Automation events -scriptRuntime.on(AutomationEventType.consoleLog, (data: any) => io.emit(AutomationEventType.consoleLog, data)); - -// Health metrics broadcast -const healthMetricsCollector = container.get('health.metricsCollector'); -setIntervalAsync(async () => { - io.emit(WebSocketEvent.healthMetrics, await healthMetricsCollector.collect()); -}, { - intervalMs: 500, - timeoutMs: 2_000, - onError: (err) => logError(logger, 'Health metrics broadcast failed', err), +process.on('uncaughtException', (error: Error) => { + logger.error('Asynchronous error caught', error); }); -const httpServer = container.get('server.http'); -const httpsServer = container.get('server.https'); - -httpServer.listen(APP_HTTP_PORT, () => { - logger.info(`Node version: ${process.version}`); - logger.info(`SlvCtrl+ server listening on http://localhost:${APP_HTTP_PORT}`); -}); +app.serve(env.PORT, sslConfig); -if (httpsServer !== undefined) { - httpsServer.listen(APP_HTTPS_PORT, () => { - logger.info(`SlvCtrl+ server listening on https://localhost:${APP_HTTPS_PORT} (ssl)`); - }); -} +const shutdown = (): void => { + app.shutdown() + .catch((err: unknown) => logError(logger, 'Error during shutdown', err)) + .finally(() => process.exit(0)); +}; -process.on('uncaughtException', (error: Error) => { - logger.error('Asynchronous error caught', error); -}); +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/src/repository/automationScriptRepository.ts b/src/repository/automationScriptRepository.ts index a587e0cc..8cff2c04 100644 --- a/src/repository/automationScriptRepository.ts +++ b/src/repository/automationScriptRepository.ts @@ -1,4 +1,6 @@ +import path from 'path'; import AutomationScript from '../entity/automationScript.js'; +import { hasProperty } from '../util/objects.js'; import AutomationScriptRepositoryInterface from './automationScriptRepositoryInterface.js'; import fs from 'fs'; @@ -27,9 +29,9 @@ export default class AutomationScriptRepository implements AutomationScriptRepos public getByName(name: string): string|null { try { - return fs.readFileSync(`${this.location}${name}`).toString(); + return fs.readFileSync(this.resolveScriptPath(name), 'utf8'); } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + if (hasProperty(e, 'code') && e.code === 'ENOENT') { return null; } @@ -39,11 +41,21 @@ export default class AutomationScriptRepository implements AutomationScriptRepos public save(fileName: string, data: string): void { - fs.writeFileSync(`${this.location}${fileName}`, data); + fs.writeFileSync(this.resolveScriptPath(fileName), data); } public delete(fileName: string): void { - fs.unlinkSync(`${this.location}${fileName}`); + fs.unlinkSync(this.resolveScriptPath(fileName)); + } + + private resolveScriptPath(fileName: string): string + { + const resolved = path.resolve(this.location, fileName); + const relative = path.relative(this.location, resolved); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Invalid script path: ${fileName}`); + } + return resolved; } } diff --git a/src/serial/synchronousSerialPort.ts b/src/serial/synchronousSerialPort.ts index fa75da86..2e8c4d8d 100644 --- a/src/serial/synchronousSerialPort.ts +++ b/src/serial/synchronousSerialPort.ts @@ -17,6 +17,8 @@ export default class SynchronousSerialPort private readonly logger: Logger; + private closed = false; + public constructor(portInfo: PortInfo, reader: Readable, writer: Writable, logger: Logger) { this.portInfo = portInfo; this.reader = reader; @@ -43,6 +45,7 @@ export default class SynchronousSerialPort ); const handleClose = (): void => { + this.closed = true; this.queue.cancel(); // Prevent second call and clean up listeners this.writer.off('close', handleClose); @@ -55,10 +58,11 @@ export default class SynchronousSerialPort } public isOpen(): boolean { - return this.writer.writable && this.reader.readable; + return !this.closed && this.writer.writable && this.reader.readable; } public close(): void { + this.closed = true; this.queue.cancel(); this.writer.end(() => { this.writer.destroy(); diff --git a/src/serviceMap.ts b/src/serviceMap.ts index b1e38a2a..28852562 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -1,7 +1,4 @@ import { Ajv } from 'ajv'; -import type http from 'http'; -import type https from 'https'; -import { Server } from 'socket.io'; import ClassToPlainSerializer from './serialization/classToPlainSerializer.js'; import PlainToClassSerializer from './serialization/plainToClassSerializer.js'; import Logger from './logging/Logger.js'; @@ -19,7 +16,7 @@ import SettingsManager from './settings/settingsManager.js'; import ScriptRuntime from './automation/scriptRuntime.js'; import ConnectedDeviceRepository from './repository/connectedDeviceRepository.js'; import AutomationScriptRepository from './repository/automationScriptRepository.js'; -import DeviceProviderLoader from './device/provider/deviceProviderLoader.js'; +import DeviceProviderManager from './device/provider/deviceProviderManager.js'; import DeviceUpdateHandler from './socket/deviceUpdateHandler.js'; import HealthController from './controller/healthController.js'; import HealthMetricsCollector from './health/healthMetricsCollector.js'; @@ -57,11 +54,6 @@ type ServiceMap = { /* loggerServiceProvider */ 'logger.default': Logger, - /* serverServiceProvider */ - 'server.http': http.Server, - 'server.https': https.Server | undefined, - 'server.websocket': Server, - /* deviceServiceProvider */ 'device.manager': DeviceManager, 'device.serial.transport.factory': SerialDeviceTransportFactory, @@ -102,7 +94,7 @@ type ServiceMap = { 'repository.connectedDevices': ConnectedDeviceRepository, 'repository.automationScript': AutomationScriptRepository, - 'device.provider.loader': DeviceProviderLoader, + 'device.provider.loader': DeviceProviderManager, 'socket.deviceUpdateHandler': DeviceUpdateHandler, /* controllerServiceProvider */ diff --git a/src/serviceProvider/automationServiceProvider.ts b/src/serviceProvider/automationServiceProvider.ts index 33e2b048..7ba75f15 100644 --- a/src/serviceProvider/automationServiceProvider.ts +++ b/src/serviceProvider/automationServiceProvider.ts @@ -1,17 +1,22 @@ import { Pimple, ServiceProvider } from '@timesplinter/pimple'; import ScriptRuntime from '../automation/scriptRuntime.js'; -import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; export default class AutomationServiceProvider implements ServiceProvider { + private readonly dataPath: string | undefined; + + public constructor(dataPath: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('automation.scriptRuntime', () => { - const logPath = `${os.homedir()}/.slvctrlplus/`; + const logPath = `${this.dataPath}/automation-logs`; if (false === fs.existsSync(logPath)) { - fs.mkdirSync(logPath); + fs.mkdirSync(logPath, { recursive: true }); } return new ScriptRuntime( diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index b569a47d..90418c36 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -8,7 +8,7 @@ import BufferedDeviceUpdater from '../device/updater/bufferedDeviceUpdater.js'; import GenericDeviceUpdater from '../device/genericDeviceUpdater.js'; import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransportFactory.js'; import Device from '../device/device.js'; -import DeviceProviderLoader from '../device/provider/deviceProviderLoader.js'; +import DeviceProviderManager from '../device/provider/deviceProviderManager.js'; import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import ButtplugIoWebsocketDeviceProvider from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import ButtplugIoWebsocketDeviceProviderFactory @@ -169,11 +169,9 @@ export default class DeviceServiceProvider implements ServiceProvider { - return new DeviceProviderLoader( + container.set('device.provider.loader', (): DeviceProviderManager => { + return new DeviceProviderManager( container.get('device.manager'), - container.get('device.observer.serial'), - container.get('settings'), new Map([ [ SlvCtrlPlusSerialDeviceProvider.providerName, diff --git a/src/serviceProvider/healthServiceProvider.ts b/src/serviceProvider/healthServiceProvider.ts index 3889ddc8..7140892f 100644 --- a/src/serviceProvider/healthServiceProvider.ts +++ b/src/serviceProvider/healthServiceProvider.ts @@ -5,6 +5,9 @@ import HealthMetricsCollector from '../health/healthMetricsCollector.js'; export default class HealthServiceProvider implements ServiceProvider { public register(container: Pimple): void { - container.set('health.metricsCollector', () => new HealthMetricsCollector()); + container.set('health.metricsCollector', () => new HealthMetricsCollector( + container.get('logger.default'), + container.get('factory.eventEmitter').create(), + )); } } diff --git a/src/serviceProvider/repositoryServiceProvider.ts b/src/serviceProvider/repositoryServiceProvider.ts index b0092c17..e29fd9bf 100644 --- a/src/serviceProvider/repositoryServiceProvider.ts +++ b/src/serviceProvider/repositoryServiceProvider.ts @@ -1,12 +1,17 @@ import { Pimple, ServiceProvider } from '@timesplinter/pimple'; import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.js'; import AutomationScriptRepository from '../repository/automationScriptRepository.js'; -import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; export default class RepositoryServiceProvider implements ServiceProvider { + private readonly dataPath: string | undefined; + + public constructor(dataPath?: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('repository.connectedDevices', () => { return new ConnectedDeviceRepository( @@ -15,10 +20,10 @@ export default class RepositoryServiceProvider implements ServiceProvider { - const scriptsPath = `${os.homedir()}/.slvctrlplus/automation-scripts/`; + const scriptsPath = `${this.dataPath}/automation-scripts`; if (false === fs.existsSync(scriptsPath)) { - fs.mkdirSync(scriptsPath); + fs.mkdirSync(scriptsPath, { recursive: true }); } return new AutomationScriptRepository(scriptsPath); diff --git a/src/serviceProvider/serverServiceProvider.ts b/src/serviceProvider/serverServiceProvider.ts deleted file mode 100644 index 33275e29..00000000 --- a/src/serviceProvider/serverServiceProvider.ts +++ /dev/null @@ -1,61 +0,0 @@ -import BaseError from 'modern-errors'; -import { Pimple, ServiceProvider } from '@timesplinter/pimple'; -import http from 'http' -import https from 'https' -import fs from 'fs' -import { Server } from 'socket.io'; -import ServiceMap from '../serviceMap.js'; -import { CorsOptions } from 'cors'; -import express from 'express'; - -export type SslConfig = { keyFile: string, certFile: string }; - -export default class ServerServiceProvider implements ServiceProvider -{ - private readonly app: express.Application; - private readonly corsOptions: CorsOptions; - private readonly sslConfig?: SslConfig; - - public constructor(app: express.Application, corsOptions: CorsOptions, sslConfig?: SslConfig) { - this.app = app; - this.corsOptions = corsOptions; - this.sslConfig = sslConfig; - } - - public register(container: Pimple): void { - container.set('server.websocket', () => new Server(undefined, { - cors: this.corsOptions - })); - - container.set('server.http', () => { - const server = http.createServer(this.app); - - container.get('server.websocket').attach(server); - - return server; - }); - - container.set('server.https', () => { - if (this.sslConfig === undefined) { - return undefined; - } - - const logger = container.get('logger.default'); - - try { - const key = fs.readFileSync(this.sslConfig.keyFile); - const cert = fs.readFileSync(this.sslConfig.certFile); - const server = https.createServer({ key, cert }, this.app); - - container.get('server.websocket').attach(server); - - return server; - } catch (err) { - const baseError = BaseError.normalize(err); - logger.error(`Failed to load SSL certificates: ${baseError.message}`); - logger.warn('HTTPS server will not be started'); - return undefined; - } - }); - } -} diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index 0ed78a56..08f64ae3 100644 --- a/src/serviceProvider/settingsServiceProvider.ts +++ b/src/serviceProvider/settingsServiceProvider.ts @@ -8,6 +8,12 @@ import { fileURLToPath } from 'url'; export default class SettingsServiceProvider implements ServiceProvider { + private readonly dataPath: string | undefined; + + public constructor(dataPath?: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('settings.schema.validator', () => { const jsonSchemaValidatorFactory = container.get('factory.validator.schema.json'); @@ -19,14 +25,16 @@ export default class SettingsServiceProvider implements ServiceProvider { - const settingsPath = `${os.homedir()}/.slvctrlplus/`; + const dataPath = this.dataPath ?? `${os.homedir()}/.slvctrlplus`; - if (false === fs.existsSync(settingsPath)) { - fs.mkdirSync(settingsPath); + if (false === fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath, { recursive: true }); } + const settingsFilePath = `${dataPath}/settings.json`; + const settingsManager = new SettingsManager( - `${settingsPath}settings.json`, + settingsFilePath, container.get('serializer.plainToClass'), container.get('serializer.classToPlain'), container.get('settings.schema.validator'), diff --git a/src/settings/serializedTypes.ts b/src/settings/serializedTypes.ts new file mode 100644 index 00000000..fb9924e0 --- /dev/null +++ b/src/settings/serializedTypes.ts @@ -0,0 +1,21 @@ +import { JsonObject } from '../types.js'; + +export type SerializedKnownDevice = { + id: string; + serialNo: string; + name: string; + type: string; + source: string; + config: JsonObject; +} + +export type SerializedDeviceSource = { + id: string; + type: string; + config: JsonObject; +} + +export type SerializedSettings = { + knownDevices: Record; + deviceSources: Record; +} diff --git a/src/socket/types.ts b/src/socket/types.ts index 3c369729..eabc32bc 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -1,3 +1,25 @@ +import { Server } from 'socket.io'; import { DeviceData } from '../device/device.js'; +import WebSocketEvent from '../device/webSocketEvent.js'; +import SettingsEventType from '../settings/settingsEventType.js'; +import AutomationEventType from '../automation/automationEventType.js'; +import { SerializedHealthMetrics } from '../health/serializedTypes.js'; +import { SerializedDevice } from '../device/serializedTypes.js'; +import { SerializedSettings } from '../settings/serializedTypes.js'; export type DeviceUpdateData = { deviceId: string, data: DeviceData } + +export interface ClientToServerEvents { + [WebSocketEvent.deviceUpdateReceived]: (data: DeviceUpdateData) => void; +} + +export interface ServerToClientEvents { + [WebSocketEvent.deviceConnected]: (data: SerializedDevice) => void; + [WebSocketEvent.deviceDisconnected]: (data: SerializedDevice) => void; + [WebSocketEvent.deviceRefreshed]: (data: SerializedDevice) => void; + [WebSocketEvent.healthMetrics]: (data: SerializedHealthMetrics) => void; + [SettingsEventType.changed]: (data: SerializedSettings) => void; + [AutomationEventType.consoleLog]: (data: string) => void; +} + +export type WebsocketServer = Server; diff --git a/src/util/expressUtils.ts b/src/util/expressUtils.ts index a77fa468..bf556c9f 100644 --- a/src/util/expressUtils.ts +++ b/src/util/expressUtils.ts @@ -1,5 +1,5 @@ import type { Request, Response } from 'express'; -import type { Pimple } from '@timesplinter/pimple'; +import type { Container } from '@timesplinter/pimple'; import type ServiceMap from '../serviceMap.js'; export type ControllerKey = { @@ -7,7 +7,7 @@ export type ControllerKey = { }[keyof ServiceMap]; export const executeController = ( - container: Pimple, + container: Container, controllerName: ControllerKey ): (req: Request, res: Response) => void | Promise => { return (req: Request, res: Response) => container.get(controllerName).execute(req, res); diff --git a/src/util/objects.ts b/src/util/objects.ts index 12205139..787102f4 100644 --- a/src/util/objects.ts +++ b/src/util/objects.ts @@ -3,3 +3,7 @@ type StringKey = Extract; export const getTypedKeys = (obj: T): (StringKey)[] => { return Object.keys(obj) as StringKey[]; } + +export const hasProperty = (obj: unknown, property: K): obj is Record => { + return typeof obj === 'object' && obj !== null && property in obj; +} diff --git a/tests/integration/api.spec.ts b/tests/integration/api.spec.ts new file mode 100644 index 00000000..f7a21f9e --- /dev/null +++ b/tests/integration/api.spec.ts @@ -0,0 +1,284 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { TEST_DEVICE_ID, createTestApp, teardownTestApp, resetTestApp, connectDevices, TestApp } from './helpers/appHelper.js'; + +describe('REST API', () => { + let app: TestApp; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await teardownTestApp(app); + }); + + beforeEach(async () => { + await resetTestApp(app); + }); + + describe('GET /version', () => { + it('returns version string', async () => { + const res = await request(app.httpServer).get('/version'); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('version'); + expect(typeof res.body.version).toBe('string'); + }); + }); + + describe('GET /health', () => { + it('returns 200 with metrics or 204 when not yet collected', async () => { + const res = await request(app.httpServer).get('/health'); + + expect(res.status).toBeOneOf([200, 204]); + }); + }); + + describe('GET /devices', () => { + it('returns connected devices', async () => { + const testDeviceName = 'Test Random Generator'; + + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: testDeviceName }]); + + const res = await request(app.httpServer).get('/devices'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ + deviceId: TEST_DEVICE_ID, + deviceName: testDeviceName, + provider: 'virtual', + deviceModel: 'randomGenerator', + fwVersion: '1.0.0', + state: 'READY', + type: 'virtual', + controllable: false, + config: { min: 0, max: 100 }, + attributes: { + value: expect.objectContaining({ + name: 'value', + label: 'Random number', + modifier: 'ro', + type: 'int', + }), + }, + })); + }); + + it('returns empty list when no devices connected', async () => { + const res = await request(app.httpServer).get('/devices'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + expect(res.body.items).toEqual([]); + }); + }); + + describe('GET /device/:deviceId', () => { + it('returns a single connected device', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const res = await request(app.httpServer).get(`/device/${TEST_DEVICE_ID}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(expect.objectContaining({ + deviceId: TEST_DEVICE_ID, + deviceName: 'Test Random Generator', + })); + }); + + it('returns 404 for unknown device', async () => { + const res = await request(app.httpServer).get('/device/unknown-id'); + + expect(res.status).toBe(404); + }); + }); + + describe('PATCH /device/:deviceId', () => { + it('returns 404 for unknown device', async () => { + const res = await request(app.httpServer) + .patch('/device/unknown-id') + .send({}); + + expect(res.status).toBe(404); + }); + + it('returns 202 for connected device', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const res = await request(app.httpServer) + .patch(`/device/${TEST_DEVICE_ID}`) + .send({}); + + expect(res.status).toBe(202); + }); + }); + + describe('GET /settings', () => { + it('returns current settings as JSON', async () => { + const res = await request(app.httpServer).get('/settings'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.body).toHaveProperty('knownDevices'); + expect(res.body).toHaveProperty('deviceSources'); + }); + }); + + describe('PUT /settings', () => { + it('accepts valid settings and returns them', async () => { + const getRes = await request(app.httpServer).get('/settings'); + const currentSettings = getRes.body; + + const res = await request(app.httpServer) + .put('/settings') + .send(currentSettings); + + expect(res.status).toBe(200); + const body = JSON.parse(res.text); + expect(body).toHaveProperty('knownDevices'); + expect(body).toHaveProperty('deviceSources'); + }); + + it('returns 400 for invalid settings', async () => { + const res = await request(app.httpServer) + .put('/settings') + .send({ invalid: true }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('errors'); + }); + }); + + describe('GET /automation/scripts', () => { + it('returns empty list when no scripts exist', async () => { + const res = await request(app.httpServer).get('/automation/scripts'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + expect(res.body.items).toEqual([]); + }); + + it('lists saved scripts', async () => { + await request(app.httpServer) + .post('/automation/scripts/test.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.httpServer).get('/automation/scripts'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ fileName: 'test.js' })); + }); + }); + + describe('POST /automation/scripts/:fileName', () => { + it('creates a script and returns its content', async () => { + const script = 'onEvent(() => {});'; + + const res = await request(app.httpServer) + .post('/automation/scripts/my-script.js') + .set('Content-Type', 'text/plain') + .send(script); + + expect(res.status).toBe(201); + expect(res.text).toBe(script); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(app.httpServer) + .post('/automation/scripts/INVALID_NAME.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + expect(res.status).toBe(400); + }); + + it('returns 400 for wrong content type', async () => { + const res = await request(app.httpServer) + .post('/automation/scripts/test.js') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ script: 'onEvent(() => {});' })); + + expect(res.status).toBe(400); + }); + }); + + describe('GET /automation/scripts/:fileName', () => { + it('returns script content as plain text', async () => { + const script = 'onEvent(() => {});'; + + await request(app.httpServer) + .post('/automation/scripts/read-test.js') + .set('Content-Type', 'text/plain') + .send(script); + + const res = await request(app.httpServer).get('/automation/scripts/read-test.js'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain/); + expect(res.text).toBe(script); + }); + + it('returns 404 for non-existent script', async () => { + const res = await request(app.httpServer).get('/automation/scripts/nonexistent.js'); + + expect(res.status).toBe(404); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(app.httpServer).get('/automation/scripts/INVALID_NAME.js'); + + expect(res.status).toBe(400); + }); + }); + + describe('DELETE /automation/scripts/:fileName', () => { + it('deletes a script and returns 204', async () => { + await request(app.httpServer) + .post('/automation/scripts/to-delete.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.httpServer).delete('/automation/scripts/to-delete.js'); + + expect(res.status).toBe(204); + + const getRes = await request(app.httpServer).get('/automation/scripts/to-delete.js'); + expect(getRes.status).toBe(404); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(app.httpServer).delete('/automation/scripts/INVALID_NAME.js'); + + expect(res.status).toBe(400); + }); + }); + + describe('GET /automation/log', () => { + it('returns log as plain text after a script has run', async () => { + await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.httpServer).get('/automation/log'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain/); + }); + }); + + describe('GET /automation/status', () => { + it('returns not running when no script is loaded', async () => { + const res = await request(app.httpServer).get('/automation/status'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(false); + expect(res.body.runningSince).toBeNull(); + }); + }); +}); diff --git a/tests/integration/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts new file mode 100644 index 00000000..0a46bef5 --- /dev/null +++ b/tests/integration/automationScripts.spec.ts @@ -0,0 +1,232 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import ScriptRuntime from '../../src/automation/scriptRuntime.js'; +import AutomationEventType from '../../src/automation/automationEventType.js'; +import { + TEST_DEVICE_ID, + createTestApp, + teardownTestApp, + resetTestApp, + connectDevices, + TestApp, +} from './helpers/appHelper.js'; + +const collectUntilMarker = ( + scriptRuntime: ScriptRuntime, + marker: string, + timeoutMs = 2000, +): Promise => { + return new Promise((resolve, reject) => { + const collected: string[] = []; + + const listener = (msg: string) => { + collected.push(msg); + if (msg === marker) { + scriptRuntime.off(AutomationEventType.consoleLog, listener); + clearTimeout(timeout); + resolve(collected); + } + }; + + const timeout = setTimeout(() => { + scriptRuntime.off(AutomationEventType.consoleLog, listener); + reject(new Error(`Timed out waiting for marker "${marker}". Got: ${JSON.stringify(collected)}`)); + }, timeoutMs); + + scriptRuntime.on(AutomationEventType.consoleLog, listener); + }); +} + +const waitForEvent = (scriptRuntime: ScriptRuntime, eventType: AutomationEventType): Promise => { + return new Promise((resolve, reject) => { + const listener = () => { + clearTimeout(timeout); + scriptRuntime.off(eventType, listener); + resolve(); + }; + const timeout = setTimeout(() => { + scriptRuntime.off(eventType, listener); + reject(new Error(`Timed out waiting for ${eventType}`)); + }, 2000); + scriptRuntime.on(eventType, listener); + }) +}; + +describe('Automation scripts', () => { + let app: TestApp; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await teardownTestApp(app); + }); + + beforeEach(async () => { + await resetTestApp(app); + }, 2000); + + describe('Script lifecycle via REST API', () => { + it('POST /automation/run loads the script and reports running status', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + + const scriptStarted = waitForEvent(scriptRuntime, AutomationEventType.scriptStarted); + + const res = await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(true); + expect(res.body.runningSince).toBeDefined(); + + await scriptStarted; + + const statusRes = await request(app.httpServer).get('/automation/status'); + expect(statusRes.status).toBe(200); + expect(statusRes.body.running).toBe(true); + }); + + it('POST /automation/run returns 400 for non-text/plain content type', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + + const res = await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ script: 'onEvent(() => {});' })); + + expect(res.status).toBe(400); + + await scriptRuntime.stop(); + }); + + it('GET /automation/stop stops the running script and emits scriptStopped', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + + await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const scriptStopped = waitForEvent(scriptRuntime, AutomationEventType.scriptStopped); + + const stopRes = await request(app.httpServer).get('/automation/stop'); + expect(stopRes.status).toBe(200); + + await scriptStopped; + + const statusRes = await request(app.httpServer).get('/automation/status'); + expect(statusRes.status).toBe(200); + expect(statusRes.body.running).toBe(false); + }); + + it('GET /automation/status returns not running when no script is loaded', async () => { + const res = await request(app.httpServer).get('/automation/status'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(false); + expect(res.body.runningSince).toBeNull(); + }); + }); + + describe('Script reacts to device events', () => { + it('onEvent is called with deviceConnected when a real device connects', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const MARKER = 'connect-done'; + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceConnected') return; + console.log(event.device.getDeviceId); + console.log(event.device.getDeviceName); + console.log('${MARKER}'); + }); + `); + + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const logs = await logsPromise; + + await scriptRuntime.stop(); + + expect(logs).toContain(TEST_DEVICE_ID); + expect(logs).toContain('Test Random Generator'); + }); + + it('onEvent receives the real attribute value on deviceRefreshed', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const MARKER = 'refresh-done'; + + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceRefreshed') return; + const attr = await event.device.getAttribute('value'); + console.log(attr !== undefined ? String(attr.value) : 'no-value'); + console.log('${MARKER}'); + }); + `); + + // collectUntilMarker is called after load() so its timeout only counts + // event-wait time, not isolate-creation/compilation time. + const logs = await collectUntilMarker(scriptRuntime, MARKER, 3000); + + await scriptRuntime.stop(); + + const valueLog = logs.find(l => l !== MARKER)!; + expect(valueLog).not.toBe('no-value'); + expect(Number(valueLog)).toBeGreaterThanOrEqual(0); + expect(Number(valueLog)).toBeLessThanOrEqual(100); + }); + + it('onEvent is called with deviceDisconnected when a real device disconnects', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const MARKER = 'disconnect-done'; + + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceDisconnected') return; + console.log(event.device.getDeviceId); + console.log('${MARKER}'); + }); + `); + + const device = app.container.get('device.manager').getConnectedDevices()[0]; + await device.close(); + + const logs = await logsPromise; + expect(logs).toContain(TEST_DEVICE_ID); + }); + + it('onStart runs once when script is loaded via API, before any device events', async () => { + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const MARKER = 'start-done'; + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await request(app.httpServer) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send(` + onStart(async () => { + console.log('init'); + console.log('${MARKER}'); + }); + onEvent(() => {}); + `); + + const logs = await logsPromise; + expect(logs).toContain('init'); + expect(logs.indexOf('init')).toBeLessThan(logs.indexOf(MARKER)); + }); + }); +}); diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts new file mode 100644 index 00000000..7943a36c --- /dev/null +++ b/tests/integration/deviceEvents.spec.ts @@ -0,0 +1,145 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; +import Device from '../../src/device/device.js'; +import Settings from '../../src/settings/settings.js'; +import KnownDevice from '../../src/settings/knownDevice.js'; +import DeviceSource from '../../src/settings/deviceSource.js'; +import RandomGeneratorVirtualDeviceLogic from '../../src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.js'; +import VirtualDevice from '../../src/device/protocol/virtual/virtualDevice.js'; +import { + TEST_DEVICE_ID, + TEST_SOURCE_ID, + NEW_DEVICE_ID, + createTestApp, + teardownTestApp, + resetTestApp, + connectDevices, + TestApp, +} from './helpers/appHelper.js'; + +describe('Device events', () => { + let app: TestApp; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await teardownTestApp(app); + }); + + beforeEach(async () => { + await resetTestApp(app); + }); + + it('virtual device connected', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const devices = app.container.get('device.manager').getConnectedDevices(); + + expect(devices).toHaveLength(1); + expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); + }); + + it('virtual device gets refreshed', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0]; + + let observedValue: number | undefined; + let changedValue: number | undefined; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceRefreshed, listener); + reject(new Error('Timed out waiting for device value to change')); + }, 1000); + + const listener = async () => { + const value = (await device.getAttribute('value'))?.value; + + if (undefined === observedValue) { + observedValue = value; + return; + } + + if (value !== observedValue) { + changedValue = value; + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceRefreshed, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceRefreshed, listener); + }); + + expect(observedValue).toBeDefined(); + expect(changedValue).toBeDefined(); + }); + + it('dynamically detects a new virtual device added to settings', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + await connectDevices(app.container, [{ id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }]); + + const devices = deviceManager.getConnectedDevices(); + + expect(devices).toHaveLength(2); + const actualDeviceIds = new Set(devices.map(d => d.getDeviceId)); + expect(actualDeviceIds).toEqual(new Set([TEST_DEVICE_ID, NEW_DEVICE_ID])); + }, 1000); + + it('dynamically removes a virtual device deleted from settings', async () => { + await connectDevices(app.container, [ + { id: TEST_DEVICE_ID, name: 'Test Random Generator' }, + { id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }, + ]); + + const deviceManager = app.container.get('device.manager'); + const settingsManager = app.container.get('settings.manager'); + + const deviceDisconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); + + const listener = (device: Device) => { + if (device.getDeviceId === NEW_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + const settingsWithOneDevice = new Settings(); + settingsWithOneDevice.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); + settingsWithOneDevice.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); + settingsManager.replace(settingsWithOneDevice); + + await deviceDisconnected; + }, 1000); + + it('virtual device disconnected', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0]; + + const disconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device disconnection')), 2000); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, () => { + clearTimeout(timeout); + resolve(); + }); + }); + + await device.close(); + await disconnected; + }); +}); diff --git a/tests/integration/devices/buttplugIoDevice.spec.ts b/tests/integration/devices/buttplugIoDevice.spec.ts new file mode 100644 index 00000000..5b6c7312 --- /dev/null +++ b/tests/integration/devices/buttplugIoDevice.spec.ts @@ -0,0 +1,195 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { DeviceAttributeModifier } from '../../../src/device/attribute/deviceAttribute.js'; +import { ButtplugIoServerSimulator } from '../helpers/buttplugIoServerSimulator.js'; +import { ActuatorType, SensorType } from 'buttplug'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; +import ButtplugIoWebsocketDeviceProvider from '../../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; + +const BUTTPLUG_SOURCE_ID = 'd5e6f7a8-5678-4321-abcd-ef1234567894'; + +function makeButtplugSettings(port: number): object { + return { + knownDevices: {}, + deviceSources: { + [BUTTPLUG_SOURCE_ID]: { + id: BUTTPLUG_SOURCE_ID, + type: ButtplugIoWebsocketDeviceProvider.providerName, + config: { + address: `127.0.0.1:${port}`, + autoScan: false, + useDeviceNameAsId: true, + }, + }, + }, + }; +} + +describe('Buttplug.io device lifecycle', () => { + let app: TestApp; + let simulator: ButtplugIoServerSimulator; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + simulator = new ButtplugIoServerSimulator(); + const simulatorPort = await simulator.start(); + + app = await createTestApp(makeButtplugSettings(simulatorPort)); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + await simulator.waitForClientReady(); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + await simulator.stop(); + }); + + beforeEach(async() => { + simulator.removeAllDevices(); + wsEmitSpy.mockClear(); + }); + + it('new device gets detected', async () => { + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + simulator.addDevice({ + name: 'MockDevice', + actuators: [ + { featureDescriptor: 'Vibrator', actuatorType: ActuatorType.Vibrate, stepCount: 20 }, + { featureDescriptor: 'Switch', actuatorType: ActuatorType.Oscillate, stepCount: 2 }, + ], + sensors: [{ featureDescriptor: 'Pressure', sensorType: SensorType.Pressure, sensorRange: [0, 100] }], + }); + + const [payload] = await deviceConnected; + + const expectedAttributes = { + provider: ButtplugIoWebsocketDeviceProvider.providerName, + type: 'buttplugIo', + attributes: { + 'Vibrate-0': { + type: 'range', + modifier: DeviceAttributeModifier.writeOnly, + min: 0, + max: 20, + }, + 'Oscillate-1': { + type: 'bool', + modifier: DeviceAttributeModifier.writeOnly, + }, + 'Pressure-0': { + type: 'range', + modifier: DeviceAttributeModifier.readOnly, + min: 0, + max: 100, + }, + }, + }; + + expect(payload).toMatchObject(expectedAttributes); + + // GET /device/:id should also return the same attributes + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedAttributes); + + // GET /devices should list the device as well and return the same attributes for it + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedAttributes); + }); + + it('attribute value can be set', async () => { + simulator.receivedScalarCmds = []; + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + simulator.addDevice({ + name: 'MockVibe', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: ActuatorType.Vibrate, stepCount: 20 }], + }); + const [payload] = await deviceConnected; + + await request(app.httpServer) + .patch(`/device/${payload.deviceId}`) + .send({ 'Vibrate-0': 10 }) + .expect(202); + + expect(simulator.receivedScalarCmds).toHaveLength(1); + const cmd = simulator.receivedScalarCmds[0]; + expect(cmd?.actuatorType).toBe('Vibrate'); + expect(cmd?.index).toBe(0); + expect(cmd?.scalar).toBeCloseTo(0.5); + + // via WebSocket event + simulator.receivedScalarCmds = []; + + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId: payload.deviceId, data: { 'Vibrate-0': 5 } }); + + const [payloadDeviceRefreshed] = await deviceRefreshed; + + expect(payloadDeviceRefreshed).toMatchObject({ deviceId: payload.deviceId }); + + expect(simulator.receivedScalarCmds).toHaveLength(1); + const wsCmd = simulator.receivedScalarCmds[0]; + expect(wsCmd?.actuatorType).toBe('Vibrate'); + expect(wsCmd?.index).toBe(0); + expect(wsCmd?.scalar).toBeCloseTo(0.25); // 5 / stepCount(20) = 0.25 + }); + + it('device refreshes', async () => { + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + const deviceIndex = simulator.addDevice({ + name: 'MockSensor2', + sensors: [{ featureDescriptor: 'Pressure', sensorType: SensorType.Pressure, sensorRange: [0, 100], reading: 42 }], + }); + + const [payloadDeviceConnected] = await deviceConnected; + + assert('deviceId' in payloadDeviceConnected); + + const nextReading = 77; + + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + simulator.setSensorReading(deviceIndex, 0, nextReading); + const [payloadDeviceRefreshed] = await deviceRefreshed; + + const expectedPayload = { deviceId: payloadDeviceConnected.deviceId, attributes: { 'Pressure-0': { value: nextReading } } }; + + expect(payloadDeviceRefreshed).toMatchObject(expectedPayload); + + const res = await request(app.httpServer).get(`/device/${payloadDeviceConnected.deviceId}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject(expectedPayload); + }); + + it('device disconnected', async () => { + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + const deviceIndex = simulator.addDevice({ + name: 'MockDevice', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: ActuatorType.Vibrate, stepCount: 20 }], + }); + + const [payload] = await deviceConnected; + + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + simulator.removeDevice(deviceIndex); + const [payloadDeviceDisconnected] = await deviceDisconnected; + + expect(payloadDeviceDisconnected).toMatchObject({ deviceId: payload.deviceId }); + + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + }); +}); diff --git a/tests/integration/devices/estim2bDevice.spec.ts b/tests/integration/devices/estim2bDevice.spec.ts new file mode 100644 index 00000000..866bfb1c --- /dev/null +++ b/tests/integration/devices/estim2bDevice.spec.ts @@ -0,0 +1,224 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import EStim2bSerialDeviceProvider from '../../../src/device/protocol/estim2b/estim2bSerialDeviceProvider.js'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { Estim2bDeviceSimulator } from '../helpers/estim2bDeviceSimulator.js'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const PORT_PATH = '/dev/test-estim2b-0'; + +const SERIAL_SOURCE_ID = 'e6f7a8b9-6789-4321-abcd-ef1234567895'; + +const serialSettings = { + knownDevices: {}, + deviceSources: { + [SERIAL_SOURCE_ID]: { + id: SERIAL_SOURCE_ID, + type: EStim2bSerialDeviceProvider.providerName, + config: {}, + }, + }, +}; + +describe('E-Stim Systems 2B serial device provider', () => { + let app: TestApp; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + app = await createTestApp(serialSettings); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + app.mockSerialPortFactory.reset(); + }); + + beforeEach(async () => { + await app.container.get('device.manager').reset(); + app.mockSerialPortFactory.reset(); + await app.container.get('device.observer.serial').discoverSerialDevices(); + wsEmitSpy.mockClear(); + }); + + it('new device gets detected', async () => { + const simulator = new Estim2bDeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + + await app.container.get('device.observer.serial').discoverSerialDevices(); + + const [payload] = await deviceConnected; + + const expectedDeviceObject = { + provider: EStim2bSerialDeviceProvider.providerName, + type: 'estim2b', + attributes: { + mode: { + name: 'mode', + modifier: 'rw', + type: 'list', + }, + channelALevel: { + name: 'channelALevel', + modifier: 'rw', + min: 0, + max: 100, + incrementStep: 1, + type: 'range', + }, + channelBLevel: { + name: 'channelBLevel', + modifier: 'rw', + min: 0, + max: 100, + incrementStep: 1, + type: 'range', + }, + highPowerMode: { + name: 'highPowerMode', + modifier: 'rw', + type: 'bool', + }, + channelsJoined: { + name: 'channelsJoined', + modifier: 'ro', + type: 'bool', + }, + batteryStatus: { + name: 'batteryStatus', + modifier: 'ro', + type: 'str', + }, + pulseFrequency: { + name: 'pulseFrequency', + modifier: 'rw', + min: 2, + max: 100, + incrementStep: 1, + type: 'range', + }, + pulsePwm: { + name: 'pulsePwm', + modifier: 'rw', + min: 2, + max: 100, + incrementStep: 1, + type: 'range', + }, + }, + config: {}, + }; + + expect(payload).toMatchObject(expectedDeviceObject); + + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedDeviceObject); + + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedDeviceObject); + }); + + it('attribute value can be set', async () => { + const simulator = new Estim2bDeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ channelALevel: 50 }) + .expect(202); + + expect(simulator.receivedCommands).toContain('A50'); + + const resAfterPatch = await request(app.httpServer).get(`/device/${deviceId}`); + expect(resAfterPatch.status).toBe(200); + expect(resAfterPatch.body.attributes.channelALevel.value).toBe(50); + + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId, data: { channelALevel: 80 } }); + await deviceRefreshed; + + expect(simulator.receivedCommands).toContain('A80'); + }); + + it('device refreshes', async () => { + const simulator = new Estim2bDeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const deviceRefreshed = waitForNextWsEvent( + wsEmitSpy, + WebSocketEvent.deviceRefreshed, + 5000, + ([p]) => p.deviceId === deviceId + && p.attributes?.channelALevel?.value === 80 + && p.attributes?.highPowerMode?.value === true, + ); + + simulator.setChannelALevel(80); + + const [refreshPayload] = await deviceRefreshed; + + const expectedPayload = { + deviceId, + attributes: { + channelALevel: { value: 80 }, + highPowerMode: { value: true }, + }, + }; + + expect(refreshPayload).toMatchObject(expectedPayload); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject(expectedPayload); + }); + + it('device disconnected', async () => { + const simulator = new Estim2bDeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const device = app.container.get('device.manager').getConnectedDevice(deviceId); + assert(device !== null); + + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + await device.close(); + const [disconnectPayload] = await deviceDisconnected; + + expect(disconnectPayload).toMatchObject({ deviceId }); + + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.items.length).toBe(0); + }); +}); diff --git a/tests/integration/devices/slvCtrlSerialDevice.spec.ts b/tests/integration/devices/slvCtrlSerialDevice.spec.ts new file mode 100644 index 00000000..e0330590 --- /dev/null +++ b/tests/integration/devices/slvCtrlSerialDevice.spec.ts @@ -0,0 +1,294 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import SlvCtrlPlusSerialDeviceProvider from '../../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { SlvCtrlPlusDeviceSimulator } from '../helpers/slvCtrlPlusDeviceSimulator.js'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const V1_PORT_PATH = '/dev/test-slvctrl-v1-0'; +const LEGACY_PORT_PATH = '/dev/test-slvctrl-legacy-0'; + +const SERIAL_SOURCE_ID = 'e6f7a8b9-6789-4321-abcd-ef1234567895'; + +const serialSettings = { + knownDevices: {}, + deviceSources: { + [SERIAL_SOURCE_ID]: { + id: SERIAL_SOURCE_ID, + type: SlvCtrlPlusSerialDeviceProvider.providerName, + config: {}, + }, + }, +}; + +describe('SlvCtrl serial device provider', () => { + let app: TestApp; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + app = await createTestApp(serialSettings); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + app.mockSerialPortFactory.reset(); + }); + + beforeEach(async () => { + // Close all connected devices before destroying the mock binding so their polling + // timers are stopped and the device-close chain completes cleanly. Without this, + // stale devices accumulate across test iterations: each one keeps a 100ms polling + // timer alive and floods the event loop with I/O errors after the binding is torn down. + await app.container.get('device.manager').reset(); + app.mockSerialPortFactory.reset(); + await app.container.get('device.observer.serial').discoverSerialDevices(); + wsEmitSpy.mockClear(); + }); + + it.each([ + { protocol: 'legacy', deviceType: 'testDeviceLegacy' }, + { protocol: 'v1', deviceType: 'testDeviceV1' }, + ] as const)('new $protocol device gets detected', async ({ protocol, deviceType }) => { + const portPath = protocol === 'v1' ? V1_PORT_PATH : LEGACY_PORT_PATH; + const simulator = new SlvCtrlPlusDeviceSimulator({ protocol, deviceType }); + app.mockSerialPortFactory.attachDevice(portPath, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + + await app.container.get('device.observer.serial').discoverSerialDevices(); + + const [payload] = await deviceConnected; + + const expectedDeviceObject = { + provider: SlvCtrlPlusSerialDeviceProvider.providerName, + type: 'slvCtrlPlus', + attributes: { + connected: { + name: 'connected', + modifier: 'ro', + type: 'bool' + }, + enabled: { + name: 'enabled', + modifier: 'rw', + type: 'bool' + }, + counter: { + name: 'counter', + modifier: 'ro', + type: 'int' + }, + level: { + name: 'level', + modifier: 'rw', + type: 'int' + }, + temperature: { + name: 'temperature', + modifier: 'ro', + type: 'float' + }, + gain: { + name: 'gain', + modifier: 'rw', + type: 'float' + }, + label: { + name: 'label', + modifier: 'ro', + type: 'str' + }, + mode: { + name: 'mode', + modifier: 'rw', + type: 'str' + }, + intensity: { + name: 'intensity', + modifier: 'rw', + min: 0, + max: 100, + incrementStep: 1, + type: 'range' + }, + preset: { + name: 'preset', + modifier: 'rw', + values: [ + { + "key": "low", + "value": "low", + }, + { + "key": "medium", + "value": "medium", + }, + { + "key": "high", + "value": "high", + } + ], + type: 'list' + }, + channel: { + name: 'channel', + modifier: 'rw', + values: [ + { + "key": (protocol === 'legacy') ? "1" : 1, + "value": (protocol === 'legacy') ? "1" : 1, + }, + { + "key": (protocol === 'legacy') ? "2" : 2, + "value": (protocol === 'legacy') ? "2" : 2, + }, + { + "key": (protocol === 'legacy') ? "3" : 3, + "value": (protocol === 'legacy') ? "3" : 3, + } + ], + type: 'list' + } + }, + config: {}, + deviceModel: deviceType, + fwVersion: 1, + }; + + expect(payload).toMatchObject(expectedDeviceObject); + + // GET /device/:id should also return the same attributes + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedDeviceObject); + + // GET /devices should list the device as well and return the same attributes for it + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedDeviceObject); + }); + + it.each([ + { protocol: 'legacy', deviceType: 'testDeviceLegacy' }, + { protocol: 'v1', deviceType: 'testDeviceV1' }, + ] as const)('$protocol attribute value can be set', async ({ protocol, deviceType }) => { + const portPath = protocol === 'v1' ? V1_PORT_PATH : LEGACY_PORT_PATH; + const simulator = new SlvCtrlPlusDeviceSimulator({ protocol, deviceType }); + app.mockSerialPortFactory.attachDevice(portPath, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Set attribute value via REST API + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ level: 7 }) + .expect(202); + + expect(simulator.getValue('level')).toBe('7'); + + const resAfterPatch = await request(app.httpServer).get(`/device/${deviceId}`); + expect(resAfterPatch.status).toBe(200); + expect(resAfterPatch.body.attributes.level.value).toBe(7); + + // Set attribute value via Websocket + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed, 5000, ([p]) => { + return p.deviceId === deviceId && p.attributes?.level?.value === 9; + }); + + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId, data: { level: 9 } }); + await deviceRefreshed; + + expect(simulator.getValue('level')).toBe('9'); + }); + + it.each([ + { protocol: 'legacy', deviceType: 'testDeviceLegacy' }, + { protocol: 'v1', deviceType: 'testDeviceV1' }, + ] as const)('$protocol device refreshes', async ({ protocol, deviceType }) => { + const portPath = protocol === 'v1' ? V1_PORT_PATH : LEGACY_PORT_PATH; + const simulator = new SlvCtrlPlusDeviceSimulator({ protocol, deviceType }); + app.mockSerialPortFactory.attachDevice(portPath, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const deviceRefreshed = waitForNextWsEvent( + wsEmitSpy, + WebSocketEvent.deviceRefreshed, + 5000, + ([p]) => p.deviceId === deviceId + && p.attributes?.level?.value === 8 + && p.attributes?.enabled?.value === true, + ); + + simulator.setValue('level', '8'); + simulator.setValue('enabled', '1'); + + const [refreshPayload] = await deviceRefreshed; + + const expectedPayload = { + deviceId, + attributes: { + level: { value: 8 }, + enabled: { value: true }, + }, + }; + + expect(refreshPayload).toMatchObject(expectedPayload); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject(expectedPayload); + }); + + it.each([ + { protocol: 'legacy', deviceType: 'testDeviceLegacy' }, + { protocol: 'v1', deviceType: 'testDeviceV1' }, + ] as const)('$protocol device disconnected', async ({ protocol, deviceType }) => { + const portPath = protocol === 'v1' ? V1_PORT_PATH : LEGACY_PORT_PATH; + const simulator = new SlvCtrlPlusDeviceSimulator({ protocol, deviceType }); + app.mockSerialPortFactory.attachDevice(portPath, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Get device by its specific ID to avoid ambiguity with stale devices + const device = app.container.get('device.manager').getConnectedDevice(deviceId); + assert(device !== null); + + // Closing the MockPortBinding via simulator.disconnect() does not propagate a 'close' event + // to the SerialPortStream reliably. Call device.close() directly instead, which is the + // correct way to trigger the device lifecycle events (same as a real port close). + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + await device.close(); + const [disconnectPayload] = await deviceDisconnected; + + expect(disconnectPayload).toMatchObject({ deviceId }); + + // The specific device must no longer appear in the device list + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.items.length).toBe(0); + }); +}); diff --git a/tests/integration/devices/zc95Device.spec.ts b/tests/integration/devices/zc95Device.spec.ts new file mode 100644 index 00000000..95934dd8 --- /dev/null +++ b/tests/integration/devices/zc95Device.spec.ts @@ -0,0 +1,327 @@ +import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient } from 'socket.io-client'; +import Zc95SerialDeviceProvider from '../../../src/device/protocol/zc95/zc95SerialDeviceProvider.js'; +import WebSocketEvent from '../../../src/device/webSocketEvent.js'; +import { Zc95DeviceSimulator } from '../helpers/zc95DeviceSimulator.js'; +import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const PORT_PATH = '/dev/test-zc95-0'; + +const SERIAL_SOURCE_ID = 'e6f7a8b9-6789-4321-abcd-ef1234567895'; + +const serialSettings = { + knownDevices: {}, + deviceSources: { + [SERIAL_SOURCE_ID]: { + id: SERIAL_SOURCE_ID, + type: Zc95SerialDeviceProvider.providerName, + config: {}, + }, + }, +}; + +describe('Zc95 serial device provider', () => { + let app: TestApp; + let wsEmitSpy: ReturnType; + let wsClient: ReturnType; + + beforeAll(async () => { + app = await createTestApp(serialSettings); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + wsClient = await createWsClient(app.httpServer); + }); + + afterAll(async () => { + wsClient.disconnect(); + await teardownTestApp(app); + app.mockSerialPortFactory.reset(); + }); + + beforeEach(async () => { + await app.container.get('device.manager').reset(); + app.mockSerialPortFactory.reset(); + await app.container.get('device.observer.serial').discoverSerialDevices(); + wsEmitSpy.mockClear(); + }); + + it('new device gets detected', async () => { + const simulator = new Zc95DeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + + await app.container.get('device.observer.serial').discoverSerialDevices(); + + const [payload] = await deviceConnected; + + const expectedDeviceObject = { + provider: Zc95SerialDeviceProvider.providerName, + type: 'zc95', + attributes: { + activePattern: { + name: 'activePattern', + modifier: 'rw', + values: [{ key: 0, value: 'Test Pattern' }], + type: 'list', + }, + patternStarted: { + name: 'patternStarted', + modifier: 'rw', + type: 'bool', + value: false, + }, + }, + config: {}, + }; + + expect(payload).toMatchObject(expectedDeviceObject); + + // GET /device/:id should also return the same attributes + const resSingleDevice = await request(app.httpServer).get(`/device/${payload.deviceId}`); + expect(resSingleDevice.status).toBe(200); + expect(resSingleDevice.body).toMatchObject(expectedDeviceObject); + + // GET /devices should list the device as well + const resDeviceList = await request(app.httpServer).get('/devices'); + expect(resDeviceList.status).toBe(200); + expect(resDeviceList.body.count).toBe(1); + expect(resDeviceList.body.items[0]).toMatchObject(expectedDeviceObject); + }); + + it('attribute value can be set', async () => { + const simulator = new Zc95DeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Set attribute value via REST API – setAttribute() calls updateLastRefresh() which emits deviceRefreshed + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ patternStarted: true }) + .expect(202); + + expect(simulator.receivedCommands.map(c => c.type)).toContain('PatternStart'); + + const resAfterPatch = await request(app.httpServer).get(`/device/${deviceId}`); + expect(resAfterPatch.status).toBe(200); + expect(resAfterPatch.body.attributes.patternStarted.value).toBe(true); + + // Set attribute value via WebSocket – setAttribute() also calls updateLastRefresh() + // so we can wait for the resulting deviceRefreshed event. + const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + + wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId, data: { patternStarted: false } }); + await deviceRefreshed; + + expect(simulator.receivedCommands.map(c => c.type)).toContain('PatternStop'); + }); + + it('started pattern exposes power channel and pattern-specific attributes', async () => { + const simulator = new Zc95DeviceSimulator({ + patterns: [ + { + id: 0, + name: 'Test Pattern', + menuItems: [ + { + Id: 1, + Title: 'Intensity', + Group: 0, + Type: 'MIN_MAX', + Default: 50, + Min: 0, + Max: 100, + IncrementStep: 1, + UoM: '%', + }, + { + Id: 2, + Title: 'Waveform', + Group: 0, + Type: 'MULTI_CHOICE', + Default: 0, + Choices: [ + { Id: 0, Name: 'Sine' }, + { Id: 1, Name: 'Square' }, + ], + }, + ], + }, + ], + }); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ patternStarted: true }) + .expect(202); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + + const attrs = res.body.attributes; + + // Core pattern-state attributes remain present + expect(attrs).toMatchObject({ + activePattern: { name: 'activePattern', modifier: 'rw', type: 'list', value: 0 }, + patternStarted: { name: 'patternStarted', modifier: 'rw', type: 'bool', value: true }, + }); + + // Four power-channel range attributes are created (min=max=0, value not yet set by device) + for (let ch = 1; ch <= 4; ch++) { + expect(attrs[`powerChannel${ch}`]).toMatchObject({ + name: `powerChannel${ch}`, + modifier: 'rw', + type: 'range', + min: 0, + max: 0, + incrementStep: 1, + }); + } + + // MIN_MAX menu item (Id=1) → IntRangeDeviceAttribute initialised to Default value + expect(attrs.patternAttribute1).toMatchObject({ + name: 'patternAttribute1', + modifier: 'rw', + type: 'range', + min: 0, + max: 100, + incrementStep: 1, + value: 50, + }); + + // MULTI_CHOICE menu item (Id=2) → ListDeviceAttribute initialised to Default choice + expect(attrs.patternAttribute2).toMatchObject({ + name: 'patternAttribute2', + modifier: 'rw', + type: 'list', + values: [ + { key: 0, value: 'Sine' }, + { key: 1, value: 'Square' }, + ], + value: 0, + }); + }); + + it('device refreshes', async () => { + // Use a pattern with real menu items so we can verify that the unsolicited + // PowerStatus message only updates the power-channel attributes and leaves + // the pattern-specific attributes untouched. + const simulator = new Zc95DeviceSimulator({ + patterns: [ + { + id: 0, + name: 'Test Pattern', + menuItems: [ + { + Id: 1, + Title: 'Intensity', + Group: 0, + Type: 'MIN_MAX', + Default: 50, + Min: 0, + Max: 100, + IncrementStep: 1, + UoM: '%', + }, + ], + }, + ], + }); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + // Start the pattern so the powerChannel and patternAttribute attributes are created. + await request(app.httpServer) + .patch(`/device/${deviceId}`) + .send({ patternStarted: true }) + .expect(202); + + // The ZC95 firmware periodically broadcasts unsolicited PowerStatus messages. + // PowerStatusMsgResponse only carries Channels (Channel, OutputPower, MaxOutputPower, + // PowerLimit). processPowerStatusMessage() updates powerChannel attributes and calls + // updateLastRefresh(), which emits deviceRefreshed. + // Raw values are converted via * 0.1: + // powerChannel1 value = Math.floor(200 * 0.1) = 20 + // powerChannel1 max = Math.floor(1000 * 0.1) = 100 + const deviceRefreshed = waitForNextWsEvent( + wsEmitSpy, + WebSocketEvent.deviceRefreshed, + 5000, + ([p]) => p.deviceId === deviceId && p.attributes?.powerChannel1?.value === 20, + ); + + simulator.sendPowerStatus([ + { channel: 1, outputPower: 200, maxOutputPower: 200, powerLimit: 1000 }, + { channel: 2, outputPower: 0, maxOutputPower: 0, powerLimit: 1000 }, + { channel: 3, outputPower: 0, maxOutputPower: 0, powerLimit: 1000 }, + { channel: 4, outputPower: 0, maxOutputPower: 0, powerLimit: 1000 }, + ]); + + const [refreshPayload] = await deviceRefreshed; + + // Power channels are updated by the PowerStatus message. + expect(refreshPayload).toMatchObject({ + deviceId, + attributes: { + powerChannel1: { value: 20, max: 100 }, + }, + }); + + const res = await request(app.httpServer).get(`/device/${deviceId}`); + expect(res.status).toBe(200); + + const attrs = res.body.attributes; + + // Power channels carry the values broadcast in the PowerStatus message. + expect(attrs.powerChannel1).toMatchObject({ value: 20, max: 100 }); + + // Pattern-specific attributes are NOT part of the PowerStatus message: + // they retain the initial values set when the pattern was started. + expect(attrs.patternAttribute1).toMatchObject({ value: 50 }); + }); + + it('device disconnected', async () => { + const simulator = new Zc95DeviceSimulator(); + app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); + + const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); + await app.container.get('device.observer.serial').discoverSerialDevices(); + const [payload] = await deviceConnected; + + const deviceId = payload.deviceId; + + const device = app.container.get('device.manager').getConnectedDevice(deviceId); + assert(device !== null); + + const deviceDisconnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceDisconnected); + await device.close(); + const [disconnectPayload] = await deviceDisconnected; + + expect(disconnectPayload).toMatchObject({ deviceId }); + + const res = await request(app.httpServer).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.items.length).toBe(0); + }); +}); diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts new file mode 100644 index 00000000..6a6bca72 --- /dev/null +++ b/tests/integration/helpers/appHelper.ts @@ -0,0 +1,246 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { io as ioClient } from 'socket.io-client'; +import { createApp, AppInstance, createContainer, AppOptions } from '../../../src/app.js'; +import Device from '../../../src/device/device.js'; +import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; +import { ServerToClientEvents } from '../../../src/socket/types.js'; +type WsEmitCall = { [E in keyof ServerToClientEvents]: [E, ...Parameters] }[keyof ServerToClientEvents]; +import KnownDevice from '../../../src/settings/knownDevice.js'; +import Settings from '../../../src/settings/settings.js'; +import DeviceSource from '../../../src/settings/deviceSource.js'; +import ServiceMap from '../../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; +import MockSerialPortFactory from './mockSerialPortFactory.js'; +import http from 'http'; +import { AddressInfo } from 'net'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +export const TEST_DEVICE_ID = 'a1b2c3d4-1234-4321-abcd-ef1234567890'; +export const TEST_SOURCE_ID = 'b2c3d4e5-2345-4321-abcd-ef1234567891'; +export const NEW_DEVICE_ID = 'c3d4e5f6-3456-4321-abcd-ef1234567892'; + +export type DeviceSpec = { id: string, name: string, config?: { min: number, max: number } }; + +function makeBaseSettings(): Settings { + const settings = new Settings(); + settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + return settings; +} + +const baseSettingsJson = { + knownDevices: {}, + deviceSources: { + [TEST_SOURCE_ID]: { + id: TEST_SOURCE_ID, + type: 'virtual', + config: { scanIntervalMs: 50 }, + }, + }, +}; + +export type TestApp = Omit & { + container: Container, + tmpDir: string, + mockSerialPortFactory: MockSerialPortFactory, + httpServer: http.Server +}; + +export const createTestApp = async ( + settingsJson: object = baseSettingsJson +): Promise => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); + const dataPath = tmpDir + path.sep; + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify(settingsJson)); + + const options: AppOptions = { dataPath, allowedOrigins: ['*'] }; + const container = createContainer(dataPath); + + const mockSerialPortFactory = new MockSerialPortFactory(); + container.replace('factory.serialPort', () => mockSerialPortFactory); + + const { websocket, serve, shutdown } = createApp(container, options); + const httpServer = serve(0).httpServer; + + return { container, tmpDir, mockSerialPortFactory, websocket, httpServer, shutdown }; +}; + +export const createWsClient = async (httpServer: http.Server): Promise> => { + const wsClient = ioClient(`http://localhost:${getServerPort(httpServer)}`); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + wsClient.close(); + reject(new Error('Failed to connect WebSocket client')); + }, 2000); + + wsClient.on('connect', () => { clearTimeout(timeout); resolve(); }); + wsClient.on('connect_error', (err) => { + clearTimeout(timeout); + wsClient.close(); + reject(err); + }); + }); + + return wsClient; +}; + +export const teardownTestApp = async (app: TestApp): Promise => { + if (app.httpServer !== undefined) { + app.httpServer.closeAllConnections(); + } + + await app.shutdown(); + + fs.rmSync(app.tmpDir, { recursive: true }); +}; + +export const resetTestApp = async (app: TestApp): Promise => { + const deviceManager = app.container.get('device.manager'); + const scriptRuntime = app.container.get('automation.scriptRuntime'); + const settingsManager = app.container.get('settings.manager'); + + if (scriptRuntime.isRunning()) { + await scriptRuntime.stop(); + } + + const connectedIds = deviceManager.getConnectedDevices().map(d => d.getDeviceId); + + if (connectedIds.length > 0) { + // Drive disconnection through settings so VirtualDeviceProvider clears its + // own attemptedDevices/connectedDevices maps via its normal discovery loop. + // Calling device.close() directly would leave those maps stale. + const remaining = new Set(connectedIds); + const allGone = new Promise((resolve, reject) => { + const cleanup = () => { + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + clearTimeout(timeout); + }; + const timeout = setTimeout( + () => { + cleanup(); + reject(new Error(`resetTestApp: ${[...remaining].join(', ')} did not disconnect within 1s`)); + }, + 1000, + ); + const listener = (device: { getDeviceId: string }) => { + remaining.delete(device.getDeviceId); + if (remaining.size === 0) { + cleanup(); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + settingsManager.replace(makeBaseSettings()); + await allGone; + } else { + settingsManager.replace(makeBaseSettings()); + } +}; + +export function getConnectedDevice( + container: Container, + predicate: (device: Device) => boolean, + description: string, +): Device { + const device = container.get('device.manager').getConnectedDevices().find(predicate); + if (undefined === device) { + throw new Error(`No connected device found: ${description}`); + } + return device; +} + +export function waitForNDevicesConnected(container: Container, deviceCount: number, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const deviceManager = container.get('device.manager'); + const connected: Device[] = []; + + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + reject(new Error(`Timed out waiting for ${deviceCount} device(s) to connect (>${timeoutMs}ms), got ${connected.length}`)); + }, timeoutMs); + + const listener = (device: Device): void => { + connected.push(device); + if (connected.length >= deviceCount) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(connected); + } + }; + + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); +} + +export function waitForNextWsEvent( + wsEmitSpy: { mock: { calls: ReadonlyArray } }, + event: E, + timeoutMs = 5000, + predicate?: (params: Parameters) => boolean, +): Promise> { + const matchingCalls = () => wsEmitSpy.mock.calls + .filter((call): call is Extract]> => call[0] === event) + .map(([, ...params]) => params as Parameters) + .filter(params => predicate === undefined || predicate(params)); + const countBefore = matchingCalls().length; + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + const poll = () => { + const calls = matchingCalls(); + if (calls.length > countBefore) { + resolve(calls[calls.length - 1]); + } else if (Date.now() >= deadline) { + reject(new Error(`Timed out waiting for WS event '${event}' (>${timeoutMs}ms)`)); + } else { + setTimeout(poll, 10); + } + }; + poll(); + }); +} + +export const getServerPort = (server: http.Server): number => { + const address = server.address(); + if (address !== null && typeof address === 'object') { + return address.port; + } + throw new Error('Server address is not an AddressInfo'); +} + +export const connectDevices = (container: Container, specs: DeviceSpec[]): Promise => { + const deviceManager = container.get('device.manager'); + const settingsManager = container.get('settings.manager'); + + const pendingIds = new Set(specs.map(s => s.id)); + + const allConnected = new Promise((resolve, reject) => { + const listener = (device: { getDeviceId: string }) => { + pendingIds.delete(device.getDeviceId); + if (pendingIds.size === 0) { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + clearTimeout(timeout); + resolve(); + } + }; + + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + reject(new Error(`Timed out waiting for devices to connect: ${[...pendingIds].join(', ')}`)); + }, 1000); + + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); + + const settings = settingsManager.load(); + for (const spec of specs) { + settings.addKnownDevice(new KnownDevice(spec.id, spec.id, spec.name, 'randomGenerator', 'virtual', spec.config ?? { min: 0, max: 100 })); + } + settingsManager.replace(settings); + + return allConnected; +}; diff --git a/tests/integration/helpers/buttplugIoServerSimulator.ts b/tests/integration/helpers/buttplugIoServerSimulator.ts new file mode 100644 index 00000000..93881bf4 --- /dev/null +++ b/tests/integration/helpers/buttplugIoServerSimulator.ts @@ -0,0 +1,266 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { createServer } from 'http'; +import { ActuatorType, SensorType, RequestServerInfo, RequestDeviceList, StartScanning, StopAllDevices, StopDeviceCmd, SensorReadCmd, ScalarCmd, StopScanning, Ping, fromJSON } from 'buttplug'; + +/** + * Defines a mock actuator on a simulated Buttplug device. + */ +export interface MockActuator { + featureDescriptor: string; + actuatorType: ActuatorType; + stepCount: number; +} + +/** + * Defines a mock sensor on a simulated Buttplug device. + * sensorRange is [min, max]. + */ +export interface MockSensor { + featureDescriptor: string; + sensorType: SensorType; + sensorRange: [number, number]; + /** Current reading to return for SensorReadCmd (defaults to 0). */ + reading?: number; +} + +export interface MockButtplugDevice { + name: string; + displayName?: string; + actuators?: MockActuator[]; + sensors?: MockSensor[]; +} + +type IncomingScalarSubcommand = { Index: number; Scalar: number; ActuatorType: ActuatorType }; + +type IncomingMessage = + RequestServerInfo | + RequestDeviceList | + StartScanning | + StopScanning | + ScalarCmd | + SensorReadCmd | + StopDeviceCmd | + StopAllDevices | + Ping; + +/** + * Minimal WebSocket server that speaks the Buttplug JSON protocol v3. + * + * Usage: + * const sim = new ButtplugIoServerSimulator(); + * const port = await sim.start(); + * // address used in provider config: `localhost:${port}` + * sim.addDevice({ name: 'MockVibe', actuators: [...], sensors: [...] }); + * ... + * await sim.stop(); + */ +export class ButtplugIoServerSimulator { + private server: ReturnType | null = null; + private wss: WebSocketServer | null = null; + private connectedClients: Set = new Set(); + private clientConnectedResolvers: Array<() => void> = []; + private clientReadyResolvers: Array<() => void> = []; + + private devices: Map = new Map(); + private nextDeviceIndex = 0; + + /** Recorded scalar commands: { deviceIndex, index, actuatorType, scalar }[] */ + public receivedScalarCmds: Array<{ + deviceIndex: number; + index: number; + actuatorType: ActuatorType; + scalar: number; + }> = []; + + public async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer(); + this.wss = new WebSocketServer({ server: this.server, path: '/buttplug' }); + + this.wss.on('connection', (ws: WebSocket) => { + this.connectedClients.add(ws); + ws.on('message', (data: Buffer) => this.handleMessage(ws, data.toString())); + ws.on('close', () => this.connectedClients.delete(ws)); + ws.on('error', () => this.connectedClients.delete(ws)); + for (const resolve of this.clientConnectedResolvers) resolve(); + this.clientConnectedResolvers = []; + }); + + this.server.listen(0, '127.0.0.1', () => { + const addr = this.server?.address(); + if (addr && typeof addr === 'object') { + resolve(addr.port); + } else { + reject(new Error('Could not determine server port')); + } + }); + + this.server.on('error', reject); + }); + } + + public removeAllDevices(): void { + for (const deviceIndex of [...this.devices.keys()]) { + this.removeDevice(deviceIndex); + } + } + + /** Resolves once the buttplug client has completed its handshake (RequestDeviceList received). */ + public waitForClientReady(timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for buttplug client to be ready (>${timeoutMs}ms)`)), timeoutMs); + this.clientReadyResolvers.push(() => { clearTimeout(timer); resolve(); }); + }); + } + + public async stop(): Promise { + for (const ws of this.connectedClients) { + ws.close(); + } + + this.connectedClients.clear(); + + await new Promise((resolve, reject) => { + if (this.wss === null) { + resolve(); + return; + } + + this.wss.close(err => err ? reject(err) : resolve()); + }); + await new Promise((resolve, reject) => { + if (this.server === null) { + resolve(); + return; + } + + this.server.close(err => err ? reject(err) : resolve()); + }); + } + + /** + * Add a device and notify all connected clients with a DeviceAdded push. + * Returns the assigned device index. + */ + public addDevice(device: MockButtplugDevice): number { + const index = this.nextDeviceIndex++; + this.devices.set(index, device); + + const msg = this.buildDeviceAddedMessage(index, device); + this.broadcast(msg); + + return index; + } + + /** + * Remove a device and notify all connected clients with a DeviceRemoved push. + */ + public removeDevice(deviceIndex: number): void { + this.devices.delete(deviceIndex); + this.broadcast(`[{"DeviceRemoved":{"Id":0,"DeviceIndex":${deviceIndex}}}]`); + } + + public setSensorReading(deviceIndex: number, sensorIndex: number, value: number): void { + const device = this.devices.get(deviceIndex); + const sensor = device?.sensors?.[sensorIndex]; + if (sensor) { + sensor.reading = value; + } + } + + private handleMessage(ws: WebSocket, raw: string): void { + for (const msg of fromJSON(raw)) { + this.handleSingleMessage(ws, msg); + } + } + + private handleSingleMessage(ws: WebSocket, msg: IncomingMessage): void { + if (msg instanceof RequestServerInfo) { + ws.send( + `[{"ServerInfo":{"Id":${msg.Id},"MessageVersion":3,"MaxPingTime":0,"ServerName":"MockButtplugServer"}}]` + ); + } else if (msg instanceof RequestDeviceList) { + const deviceList = [...this.devices.entries()].map(([idx, dev]) => + JSON.stringify(this.buildDeviceInfo(idx, dev)) + ); + ws.send( + `[{"DeviceList":{"Id":${msg.Id},"Devices":[${deviceList.join(',')}]}}]` + ); + for (const resolve of this.clientReadyResolvers) resolve(); + this.clientReadyResolvers = []; + } else if (msg instanceof StartScanning) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + ws.send(`[{"ScanningFinished":{"Id":0}}]`); + } else if (msg instanceof StopScanning) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } else if (msg instanceof ScalarCmd) { + for (const s of msg.Scalars) { + this.receivedScalarCmds.push({ + deviceIndex: msg.DeviceIndex, + index: s.Index, + actuatorType: s.ActuatorType, + scalar: s.Scalar, + }); + } + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } else if (msg instanceof SensorReadCmd) { + const device = this.devices.get(msg.DeviceIndex); + const sensor = device?.sensors?.[msg.SensorIndex]; + const reading = sensor?.reading ?? 0; + ws.send( + `[{"SensorReading":{"Id":${msg.Id},"DeviceIndex":${msg.DeviceIndex},"SensorIndex":${msg.SensorIndex},"SensorType":"${msg.SensorType}","Data":[${reading},0]}}]` + ); + } else if (msg instanceof StopDeviceCmd) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } else if (msg instanceof StopAllDevices) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } else if (msg instanceof Ping) { + ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + } + } + + private broadcast(msg: string): void { + for (const ws of this.connectedClients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } + } + } + + private buildDeviceAddedMessage(index: number, device: MockButtplugDevice): string { + return `[{"DeviceAdded":${JSON.stringify({ + Id: 0, + ...this.buildDeviceInfo(index, device), + })}}]`; + } + + private buildDeviceInfo(index: number, device: MockButtplugDevice): object { + const deviceMessages: Record = {}; + + if (device.actuators && device.actuators.length > 0) { + deviceMessages['ScalarCmd'] = device.actuators.map((a, i) => ({ + FeatureDescriptor: a.featureDescriptor, + ActuatorType: a.actuatorType, + StepCount: a.stepCount, + Index: i, + })); + } + + if (device.sensors && device.sensors.length > 0) { + deviceMessages['SensorReadCmd'] = device.sensors.map((s, i) => ({ + FeatureDescriptor: s.featureDescriptor, + SensorType: s.sensorType, + SensorRange: s.sensorRange, + Index: i, + })); + } + + return { + DeviceIndex: index, + DeviceName: device.name, + DeviceDisplayName: device.displayName ?? device.name, + DeviceMessageTimingGap: 0, + DeviceMessages: deviceMessages, + }; + } +} diff --git a/tests/integration/helpers/estim2bDeviceSimulator.ts b/tests/integration/helpers/estim2bDeviceSimulator.ts new file mode 100644 index 00000000..a4613cd6 --- /dev/null +++ b/tests/integration/helpers/estim2bDeviceSimulator.ts @@ -0,0 +1,166 @@ +import type { SerialPortMock } from 'serialport'; + +type MockPortBinding = NonNullable['port']>; + +export class Estim2bDeviceSimulator { + private port: MockPortBinding | null = null; + + private batteryLevel: number; + private channelALevel: number; + private channelBLevel: number; + private pulseFrequency: number; + private pulsePwm: number; + private currentMode: number; + private powerMode: 'H' | 'L'; + private channelsJoined: boolean; + private readonly firmwareVersion: string; + + public readonly receivedCommands: string[] = []; + + public constructor(config?: { firmwareVersion?: string }) { + this.batteryLevel = 800; + this.channelALevel = 0; + this.channelBLevel = 0; + this.pulseFrequency = 50; + this.pulsePwm = 50; + this.currentMode = 0; + this.powerMode = 'H'; + this.channelsJoined = false; + this.firmwareVersion = config?.firmwareVersion ?? 'test-1.0'; + } + + public attachToPort(bindingPort: MockPortBinding): void { + this.port = bindingPort; + const originalWrite = bindingPort.write.bind(bindingPort); + + bindingPort.write = async (buffer: Buffer): Promise => { + await originalWrite(buffer); + + const raw = buffer.toString('utf-8'); + const command = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + + this.receivedCommands.push(command); + + this.handleCommand(command); + + setImmediate(() => { + bindingPort.emitData(Buffer.from(this.buildStatusResponse() + '\n', 'utf-8')); + }); + }; + } + + public disconnect(): Promise { + if (this.port === null) { + return Promise.resolve(); + } + return this.port.close(); + } + + public setChannelALevel(value: number): void { + this.channelALevel = value; + } + + public setChannelBLevel(value: number): void { + this.channelBLevel = value; + } + + public setMode(mode: number): void { + this.currentMode = mode; + } + + public setPowerMode(mode: 'H' | 'L'): void { + this.powerMode = mode; + } + + public setPulseFrequency(value: number): void { + this.pulseFrequency = value; + } + + public setPulsePwm(value: number): void { + this.pulsePwm = value; + } + + public setBatteryLevel(adc: number): void { + this.batteryLevel = adc; + } + + private handleCommand(command: string): void { + if (command === '') { + return; + } + + if (command === 'H') { + this.powerMode = 'H'; + return; + } + + if (command === 'L') { + this.powerMode = 'L'; + return; + } + + if (command === 'K') { + this.channelALevel = 0; + this.channelBLevel = 0; + return; + } + + if (command === 'E') { + this.channelALevel = 0; + this.channelBLevel = 0; + this.currentMode = 0; + this.pulseFrequency = 50; + this.pulsePwm = 50; + this.powerMode = 'L'; + return; + } + + if (command.startsWith('A')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 0 && value <= 99) { + this.channelALevel = value; + } + return; + } + + if (command.startsWith('B')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 0 && value <= 99) { + this.channelBLevel = value; + } + return; + } + + if (command.startsWith('M')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 0 && value <= 16) { + this.currentMode = value; + } + return; + } + + if (command.startsWith('C')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 2 && value <= 100) { + this.pulseFrequency = value; + } + return; + } + + if (command.startsWith('D')) { + const value = parseInt(command.substring(1), 10); + if (!isNaN(value) && value >= 2 && value <= 100) { + this.pulsePwm = value; + } + return; + } + } + + private buildStatusResponse(): string { + const chARaw = Math.round(this.channelALevel * 2); + const chBRaw = Math.round(this.channelBLevel * 2); + const freqRaw = Math.round(this.pulseFrequency * 2); + const pwmRaw = Math.round(this.pulsePwm * 2); + return `${this.batteryLevel}:${chARaw}:${chBRaw}:${freqRaw}:${pwmRaw}:${this.currentMode}:${this.powerMode}:${this.channelsJoined ? 1 : 0}:${this.firmwareVersion}`; + } +} diff --git a/tests/integration/helpers/mockSerialPortFactory.ts b/tests/integration/helpers/mockSerialPortFactory.ts new file mode 100644 index 00000000..1175bff2 --- /dev/null +++ b/tests/integration/helpers/mockSerialPortFactory.ts @@ -0,0 +1,68 @@ +import { SerialPort, SerialPortMock } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import { SerialPortOpenOptions } from 'serialport'; +import { AutoDetectTypes } from '@serialport/bindings-cpp'; +import SerialPortFactory from '../../../src/factory/serialPortFactory.js'; + +export interface DeviceSimulator { + attachToPort(bindingPort: NonNullable['port']>): void; +} + +/** + * A SerialPortFactory that creates SerialPortMock instances backed by + * MockBinding instead of real hardware. + * + * Supports routing different port paths to different simulators via a Map. + */ +export default class MockSerialPortFactory extends SerialPortFactory { + private readonly simulators: Map = new Map(); + + // Incremented on every attachDevice call so each mock port gets a unique serialNumber. + // Without this the SerialPortObserver synthesizes the same "serial-1234-5678-undefined" + // for every mock port (vendorId + productId + missing locationId), causing all devices + // across all test iterations to share a single UUID via createKnownDevice. A unique + // serial per connection means each device gets its own UUID and stale-device async-close + // events can never accidentally evict a different test's device from the DeviceManager map. + private productId = 1000; + + public constructor() { + super(); + + SerialPort.list = () => SerialPortMock.list(); + } + + public attachDevice(path: string, simulator: DeviceSimulator): void { + SerialPortMock.binding.createPort(path, { echo: false, record: false, vendorId: '1234', productId: String(++this.productId), manufacturer: 'NotArduino' }); + + this.simulators.set(path, simulator); + } + + public reset(): void { + this.simulators.clear(); + SerialPortMock.binding.reset(); + } + + public override create(options: SerialPortOpenOptions): SerialPortStream { + const mockPort = new SerialPortMock({ + path: options.path, + baudRate: options.baudRate ?? 9600, + autoOpen: false, + }); + + const simulator = this.simulators.get(options.path); + + if (!simulator) { + throw new Error(`No simulator attached for path ${options.path}`); + } + + // Attach the simulator once the port binding is ready (after open). + mockPort.once('open', () => { + if (mockPort.port && simulator) { + simulator.attachToPort(mockPort.port); + } + }); + + return mockPort; + } +} diff --git a/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts b/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts new file mode 100644 index 00000000..8dfee50a --- /dev/null +++ b/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts @@ -0,0 +1,190 @@ +import type { SerialPortMock } from 'serialport'; + +type MockPortBinding = NonNullable['port']>; + +export interface SlvCtrlPlusDeviceSimulatorConfig { + deviceType?: string; + fwVersion?: number; + protocolVersion?: number; + protocol?: 'v1' | 'legacy'; +} + +/** + * Simulates a SlvCtrl+ device backed by a MockPortBinding. + * + * Supports both the V1 protocol and the legacy ( = { + connected: '1', + enabled: '0', + counter: '42', + level: '5', + temperature: '36.6', + gain: '0.5', + label: 'TestDevice', + mode: 'manual', + intensity: '0', + preset: 'low', + channel: '1', + }; + + public constructor(config: SlvCtrlPlusDeviceSimulatorConfig = {}) { + this.deviceType = config.deviceType ?? 'testDevice'; + this.fwVersion = config.fwVersion ?? 1; + this.protocol = config.protocol ?? 'v1'; + // Legacy protocol version defaults to 0 to signal pre-V1; V1 defaults to 1 + this.protocolVersion = config.protocolVersion ?? (this.protocol === 'legacy' ? 0 : 1); + } + + /** Wire the simulator to a MockPortBinding once the port is open. */ + public attachToPort(bindingPort: MockPortBinding): void { + this.port = bindingPort; + const originalWrite = bindingPort.write.bind(bindingPort); + + bindingPort.write = async (buffer: Buffer): Promise => { + await originalWrite(buffer); + + const command = buffer.toString('utf-8').trimEnd(); + const response = this.respond(command); + + if (null !== response) { + // Use setImmediate so the response lands after writeAndExpect sets up its + // data listener (the listener is registered before write() returns). + setImmediate(() => { + bindingPort.emitData(Buffer.from(response + '\n', 'utf-8')); + }); + } + }; + } + + /** Simulate the hardware being unplugged by closing the port binding. */ + public disconnect(): Promise { + if (this.port === null) { + return Promise.resolve(); + } + return this.port.close(); + } + + public getValue(name: string): string | undefined { + return this.values[name]; + } + + public setValue(name: string, value: string): void { + this.values[name] = value; + } + + private get attributeDefs(): string[] { + return this.protocol === 'legacy' ? this.legacyAttributeDefs : this.v1AttributeDefs; + } + + private respond(command: string): string | null { + if (command === 'clear') { + return this.protocol === 'legacy' ? 'clear;;ok' : 'clear;;status:ok'; + } + + if (command === 'introduce') { + if (this.protocol === 'legacy') { + // Legacy format: positional, no status suffix — detected by the device factory + // via the regex /^introduce;([^,;]+),(\d+),(\d+)$/ + return `introduce;${this.deviceType},${this.fwVersion},${this.protocolVersion}`; + } + return `introduce;type:${this.deviceType},fw:${this.fwVersion},protocol:${this.protocolVersion};status:ok`; + } + + if (command === 'attributes') { + const attrStr = this.attributeDefs.join(','); + return this.protocol === 'legacy' + ? `attributes;${attrStr};ok` + : `attributes;${attrStr};status:ok`; + } + + if (command === 'status') { + const statusStr = Object.entries(this.values) + .map(([k, v]) => `${k}:${v}`) + .join(','); + return this.protocol === 'legacy' + ? `status;${statusStr};ok` + : `status;${statusStr};status:ok`; + } + + // V1 set command: "set level 5" + if (this.protocol === 'v1' && command.startsWith('set ')) { + const parts = command.split(' '); + const attrName = parts[1]; + const value = parts.slice(2).join(' '); + if (undefined !== attrName) { + this.values[attrName] = value; + } + return `${command};value:${value};status:ok`; + } + + // Legacy set command: "set-level 5" + if (this.protocol === 'legacy' && command.startsWith('set-')) { + const withoutPrefix = command.slice(4); // strip "set-" + const spaceIdx = withoutPrefix.indexOf(' '); + const attrName = spaceIdx >= 0 ? withoutPrefix.slice(0, spaceIdx) : withoutPrefix; + const value = spaceIdx >= 0 ? withoutPrefix.slice(spaceIdx + 1) : ''; + if (attrName) { + this.values[attrName] = value; + } + // Legacy result suffix is plain "ok", not "status:ok" + return `${command};${value};ok`; + } + + return null; + } +} diff --git a/tests/integration/helpers/zc95DeviceSimulator.ts b/tests/integration/helpers/zc95DeviceSimulator.ts new file mode 100644 index 00000000..b4cb8326 --- /dev/null +++ b/tests/integration/helpers/zc95DeviceSimulator.ts @@ -0,0 +1,179 @@ +import type { SerialPortMock } from 'serialport'; + +type MockPortBinding = NonNullable['port']>; + +const STX = 0x02; +const ETX = 0x03; +const EOT = 0x04; + +interface Zc95SimulatorPattern { + id: number; + name: string; + menuItems?: Array<{ + Id: number; + Title: string; + Group?: number; + Type: 'MIN_MAX' | 'MULTI_CHOICE'; + Default: number; + Min?: number; + Max?: number; + IncrementStep?: number; + UoM?: string; + Choices?: Array<{ Id: number; Name: string }>; + }>; +} + +export class Zc95DeviceSimulator { + private port: MockPortBinding | null = null; + + private readonly fwVersion: string; + private readonly patterns: Zc95SimulatorPattern[]; + + public readonly receivedCommands: Array<{ type: string; msgId: number; data: Record }> = []; + + public constructor(config?: { fwVersion?: string; patterns?: Zc95SimulatorPattern[] }) { + this.fwVersion = config?.fwVersion ?? 'test-v1.0'; + this.patterns = config?.patterns ?? [{ id: 0, name: 'Test Pattern', menuItems: [] }]; + } + + public attachToPort(bindingPort: MockPortBinding): void { + this.port = bindingPort; + const originalWrite = bindingPort.write.bind(bindingPort); + + bindingPort.write = async (buffer: Buffer): Promise => { + await originalWrite(buffer); + + if (buffer.length === 1 && buffer[0] === EOT) { + return; + } + + const stxIdx = buffer.indexOf(STX); + const etxIdx = buffer.lastIndexOf(ETX); + + if (stxIdx < 0 || etxIdx <= stxIdx) { + return; + } + + const jsonStr = buffer.toString('utf-8', stxIdx + 1, etxIdx); + let msg: { Type: string; MsgId: number }; + try { + msg = JSON.parse(jsonStr); + } catch { + return; + } + + this.receivedCommands.push({ type: msg.Type, msgId: msg.MsgId, data: msg as unknown as Record }); + + const response = this.handleMessage(msg); + if (null !== response) { + const framed = Buffer.concat([ + Buffer.from([STX]), + Buffer.from(JSON.stringify(response), 'utf-8'), + Buffer.from([ETX]), + ]); + setImmediate(() => { + bindingPort.emitData(framed); + }); + } + }; + } + + public disconnect(): Promise { + if (this.port === null) { + return Promise.resolve(); + } + return this.port.close(); + } + + /** + * Emit an unsolicited PowerStatus message, mimicking the ZC95 firmware's behaviour of + * periodically broadcasting channel power readings without a corresponding request. + * + * The device handles this in `processPowerStatusMessage` (MsgId === -1, Type === 'PowerStatus'), + * updates the powerChannel attributes and calls `updateLastRefresh()`, which emits + * `DeviceEvent.deviceRefreshed`. + * + * Power values are in raw units (0–1000); the device converts them to percentages via * 0.1. + */ + public sendPowerStatus(channels: Array<{ + channel: number; + outputPower: number; + maxOutputPower: number; + powerLimit: number; + }>): void { + if (this.port === null) { + return; + } + + const msg = { + Type: 'PowerStatus', + MsgId: -1, + Result: 'OK', + Channels: channels.map(c => ({ + Channel: c.channel, + OutputPower: c.outputPower, + MaxOutputPower: c.maxOutputPower, + PowerLimit: c.powerLimit, + })), + }; + + const framed = Buffer.concat([ + Buffer.from([STX]), + Buffer.from(JSON.stringify(msg), 'utf-8'), + Buffer.from([ETX]), + ]); + + this.port.emitData(framed); + } + + private handleMessage(msg: { Type: string; MsgId: number }): Record | null { + switch (msg.Type) { + case 'GetVersion': + return { + Type: 'VersionDetails', + MsgId: msg.MsgId, + Result: 'OK', + ZC95: this.fwVersion, + WsMajor: 1, + WsMinor: 0, + }; + + case 'GetPatterns': + return { + Type: 'PatternList', + MsgId: msg.MsgId, + Result: 'OK', + Patterns: this.patterns.map(p => ({ Id: p.id, Name: p.name })), + }; + + case 'GetPatternDetail': { + const id = String((msg as Record).Id); + const pattern = this.patterns.find(p => String(p.id) === id) ?? this.patterns[0]; + return { + Type: 'PatternDetail', + MsgId: msg.MsgId, + Result: 'OK', + Name: pattern.name, + Id: pattern.id, + ButtonA: '', + MenuItems: pattern.menuItems ?? [], + }; + } + + case 'PatternStart': + case 'PatternStop': + case 'PatternMinMaxChange': + case 'PatternMultiChoiceChange': + case 'SetPower': + case 'PatternSoftButton': + return { + Type: 'Ack', + MsgId: msg.MsgId, + Result: 'OK', + }; + + default: + return null; + } + } +} diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index e0a1a8d6..638ffdcf 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { EventEmitter } from 'events'; import { tmpdir } from 'os'; -import { ScriptRuntime, SupportedDeviceEvent } from '../../../src/automation/scriptRuntime.js'; +import 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'; diff --git a/tests/unit/controller/healthController.spec.ts b/tests/unit/controller/healthController.spec.ts new file mode 100644 index 00000000..65e1e1d7 --- /dev/null +++ b/tests/unit/controller/healthController.spec.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import HealthController from '../../../src/controller/healthController.js'; +import HealthMetricsCollector from '../../../src/health/healthMetricsCollector.js'; +import { SerializedHealthMetrics } from '../../../src/health/serializedTypes.js'; + +describe('HealthController', () => { + it('returns 204 when no metrics have been collected yet', () => { + const collector = mock(); + collector.collect.mockReturnValue(null); + + const req = mock(); + const res = mock(); + res.sendStatus.mockReturnValue(res); + + new HealthController(collector).execute(req, res); + + expect(res.sendStatus).toHaveBeenCalledWith(204); + expect(res.json).not.toHaveBeenCalled(); + }); + + it('returns 200 with metrics once collection has run', () => { + const metrics = mock(); + const collector = mock(); + collector.collect.mockReturnValue(metrics); + + const req = mock(); + const res = mock(); + res.json.mockReturnValue(res); + + new HealthController(collector).execute(req, res); + + expect(res.json).toHaveBeenCalledWith(metrics); + expect(res.sendStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/device/testDeviceProvider.ts b/tests/unit/device/testDeviceProvider.ts index 4b177fea..fa6adcbf 100644 --- a/tests/unit/device/testDeviceProvider.ts +++ b/tests/unit/device/testDeviceProvider.ts @@ -2,11 +2,9 @@ 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 {DeviceAttributes} from "../../../src/device/device.js"; -import {AnyDeviceConfig} from "../../../src/device/deviceConfig.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/vitest.config.integration.ts b/vitest.config.integration.ts new file mode 100644 index 00000000..7c052e66 --- /dev/null +++ b/vitest.config.integration.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./tests/vitest.setup.ts"], + include: ["tests/integration/**/*.spec.ts"], + testTimeout: 10000, + hookTimeout: 10000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 96a3abbd..f80b7830 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: "node", setupFiles: ["./tests/vitest.setup.ts"], + include: ["tests/unit/**/*.spec.ts"], coverage: { provider: "v8", reporter: ["text", "html", "lcov"],