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..9463c96e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,9 +46,12 @@ jobs: - 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..8260001d 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", @@ -38,9 +38,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 +53,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 +1882,19 @@ "@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/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1886,6 +1905,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 +2012,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2003,9 +2029,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2023,9 +2046,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2043,9 +2063,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2063,9 +2080,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2083,9 +2097,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2402,7 +2413,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 +2457,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 +2514,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 +2555,30 @@ "@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/ws": { "version": "8.18.1", "license": "MIT", @@ -3095,6 +3146,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 +3190,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 +3611,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 +3645,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 +3697,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 +3858,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 +3885,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 +3963,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 +4705,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 +5879,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5715,9 +5900,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5739,9 +5921,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5763,9 +5942,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5936,6 +6112,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 +7617,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 +7886,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, @@ -8501,6 +8752,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..5985b8ae 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", @@ -36,9 +36,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 +51,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 +65,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..78d4fb74 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,231 @@ +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 { DeviceUpdateData } 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 { setIntervalAsync } from './util/async.js'; +import { logError } from './util/error.js'; +import http from 'http' +import https from 'https' +import { Server } from 'socket.io'; +import fs from 'fs' +import BaseError from 'modern-errors'; + +export type SslConfig = { port: number, keyFile: string, certFile: string }; + +export interface AppOptions { + allowedOrigins: string[]; + dataPath: string; +} + +export interface AppInstance { + instance: express.Application; + websocket: Server; + serve: (httpPort: number, sslConfig?: SslConfig) => void; +} + +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: Server, 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, then broadcast cached value on each tick + healthMetricsCollector.start(1000); + + setIntervalAsync(async () => { + const metrics = healthMetricsCollector.collect(); + if (metrics !== null) { + io.emit(WebSocketEvent.healthMetrics, metrics); + } + }, { + intervalMs: 500, + timeoutMs: 1_000, + onError: (err) => logError(logger, 'Health metrics broadcast failed', err), + }); + + // 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)); + }); + + 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: unknown) => 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.init().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()) +; + +export const createApp = (container: Container, options: AppOptions): AppInstance => { + const corsOptions = buildCorsOptions(options.allowedOrigins); + const 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); + + return { + instance: app, + websocket: websocketServer, + serve: (httpPort: number, sslConfig?: SslConfig): void => { + + const logger = container.get('logger.default'); + + const httpServer = http.createServer(app); + + websocketServer.attach(httpServer); + + httpServer.listen(httpPort, () => { + logger.info(`SlvCtrl+ server listening on http://localhost:${httpPort}`); + }); + + if (sslConfig === undefined) { + return; + } + + try { + const key = fs.readFileSync(sslConfig.keyFile); + const cert = fs.readFileSync(sslConfig.certFile); + const httpsServer = https.createServer({ key, cert }, app); + + websocketServer.attach(httpsServer); + + httpsServer.listen(sslConfig.port, () => { + logger.info(`SlvCtrl+ server listening on https://localhost:${sslConfig.port} (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'); + return undefined; + } + } + }; +} diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 23d7b504..68527061 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; @@ -296,8 +296,23 @@ export class ScriptRuntime public async stop(): Promise { + // 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 +329,6 @@ export class ScriptRuntime } } - this.dispatchRef = null; this.lifecycleRef = null; this.pendingEventDone = null; this.pendingLifecycleDone = null; @@ -366,15 +380,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 +399,7 @@ export class ScriptRuntime } } - this.processingQueue = false; + this.processQueuePromise = null; } public async getLog(maxLines: number): Promise @@ -417,6 +429,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/deviceManager.ts b/src/device/deviceManager.ts index e2a9f96a..81e4c5ba 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -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/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..8a3eed2c 100644 --- a/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts @@ -30,7 +30,13 @@ export default class RandomGeneratorVirtualDeviceLogic extends VirtualDeviceLogi public async refreshData( device: VirtualDevice ): Promise { - const newNumber = Math.floor(Math.random() * (this.config.max - this.config.min + 1)) + this.config.min; + const currentNumber = (await device.getAttribute('value'))?.value; // Just to simulate some async work + 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 65% rename from src/device/provider/deviceProviderLoader.ts rename to src/device/provider/deviceProviderManager.ts index 63cf5799..fd11f116 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,19 @@ 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 { + for (const provider of this.providers) { + await provider.stop(); + } + } } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 629cf615..3c10ae7e 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'; @@ -119,11 +120,11 @@ 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; + protected abstract connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } 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..9d17a5c0 100644 --- a/src/health/healthMetricsCollector.ts +++ b/src/health/healthMetricsCollector.ts @@ -1,6 +1,9 @@ import os from 'os'; import process from 'process'; import { NetworkStats, OSUtils } from 'node-os-utils'; +import { IntervalAsync, setIntervalAsync } from '../util/async.js'; +import Logger from '../logging/Logger.js'; +import { logError } from '../util/error.js'; export type HealthMetrics = { process: { @@ -39,15 +42,45 @@ export default class HealthMetricsCollector { private readonly osUtils: OSUtils; - public constructor() + private readonly logger: Logger; + + private currentMetrics: HealthMetrics | null = null; + + private intervalHandle: IntervalAsync | null = null; + + public constructor(logger: Logger) { + this.logger = logger; 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; + } + + public collect(): HealthMetrics | 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 +92,7 @@ export default class HealthMetricsCollector this.osUtils.network.interfaces(), ]); - return { + this.currentMetrics = { process: { memoryUsage: process.memoryUsage(), }, diff --git a/src/index.ts b/src/index.ts index 0b86f7f7..8a304441 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,191 +1,28 @@ 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 { logError } from './util/error.js'; -import { setIntervalAsync } from './util/async.js'; -import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; +import { createApp, createContainer, SslConfig } from './app.js'; +import { parseEnv } from './env.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), -}); - -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}`); -}); - -if (httpsServer !== undefined) { - httpsServer.listen(APP_HTTPS_PORT, () => { - logger.info(`SlvCtrl+ server listening on https://localhost:${APP_HTTPS_PORT} (ssl)`); - }); -} process.on('uncaughtException', (error: Error) => { logger.error('Asynchronous error caught', error); }); + +app.serve(env.PORT, sslConfig); diff --git a/src/repository/automationScriptRepository.ts b/src/repository/automationScriptRepository.ts index a587e0cc..4045c94b 100644 --- a/src/repository/automationScriptRepository.ts +++ b/src/repository/automationScriptRepository.ts @@ -27,7 +27,7 @@ export default class AutomationScriptRepository implements AutomationScriptRepos public getByName(name: string): string|null { try { - return fs.readFileSync(`${this.location}${name}`).toString(); + return fs.readFileSync(`${this.location}/${name}`).toString(); } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') { return null; @@ -39,11 +39,11 @@ export default class AutomationScriptRepository implements AutomationScriptRepos public save(fileName: string, data: string): void { - fs.writeFileSync(`${this.location}${fileName}`, data); + fs.writeFileSync(`${this.location}/${fileName}`, data); } public delete(fileName: string): void { - fs.unlinkSync(`${this.location}${fileName}`); + fs.unlinkSync(`${this.location}/${fileName}`); } } 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..c81f90ed 100644 --- a/src/serviceProvider/healthServiceProvider.ts +++ b/src/serviceProvider/healthServiceProvider.ts @@ -5,6 +5,8 @@ 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'), + )); } } 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/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/tests/integration/api.spec.ts b/tests/integration/api.spec.ts new file mode 100644 index 00000000..81e2cd8f --- /dev/null +++ b/tests/integration/api.spec.ts @@ -0,0 +1,289 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { TEST_DEVICE_ID, createTestApp, teardownTestApp, resetTestApp, connectDevices } from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; +import { AppInstance } from '../../src/app.js'; + +describe('REST API', () => { + let app: AppInstance; + let container: Container; + let tmpDir: string; + + beforeAll(async () => { + ({ app, container, tmpDir } = await createTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(app, container, tmpDir); + }); + + beforeEach(async () => { + await resetTestApp(app, container); + }); + + describe('GET /version', () => { + it('returns version string', async () => { + const res = await request(app.instance).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.instance).get('/health'); + + expect(res.status).toBeOneOf([200, 204]); + }); + }); + + describe('GET /devices', () => { + it('returns connected devices', async () => { + const testDeviceName = 'Test Random Generator'; + + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: testDeviceName }]); + + const res = await request(app.instance).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.instance).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(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const res = await request(app.instance).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.instance).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.instance) + .patch('/device/unknown-id') + .send({}); + + expect(res.status).toBe(404); + }); + + it('returns 202 for connected device', async () => { + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const res = await request(app.instance) + .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.instance).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.instance).get('/settings'); + const currentSettings = getRes.body; + + const res = await request(app.instance) + .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.instance) + .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.instance).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.instance) + .post('/automation/scripts/test.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.instance).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.instance) + .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.instance) + .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.instance) + .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.instance) + .post('/automation/scripts/read-test.js') + .set('Content-Type', 'text/plain') + .send(script); + + const res = await request(app.instance).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.instance).get('/automation/scripts/nonexistent.js'); + + expect(res.status).toBe(404); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(app.instance).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.instance) + .post('/automation/scripts/to-delete.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.instance).delete('/automation/scripts/to-delete.js'); + + expect(res.status).toBe(204); + + const getRes = await request(app.instance).get('/automation/scripts/to-delete.js'); + expect(getRes.status).toBe(404); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(app.instance).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.instance) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(app.instance).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.instance).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..a19b9d10 --- /dev/null +++ b/tests/integration/automationScripts.spec.ts @@ -0,0 +1,237 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { AppInstance } from '../../src/app.js'; +import ScriptRuntime from '../../src/automation/scriptRuntime.js'; +import AutomationEventType from '../../src/automation/automationEventType.js'; +import { + TEST_DEVICE_ID, + createTestApp, + teardownTestApp, + resetTestApp, + connectDevices, +} from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; +import { appendFileSync } from 'fs'; + +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: AppInstance; + let tmpDir: string; + let container: Container; + + beforeAll(async () => { + ({ app, container, tmpDir } = await createTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(app, container, tmpDir); + }); + + beforeEach(async () => { + await resetTestApp(app, container); + }, 2000); + + describe('Script lifecycle via REST API', () => { + it('POST /automation/run loads the script and reports running status', async () => { + const scriptRuntime = container.get('automation.scriptRuntime'); + + const scriptStarted = waitForEvent(scriptRuntime, AutomationEventType.scriptStarted); + + const res = await request(app.instance) + .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.instance).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 = container.get('automation.scriptRuntime'); + + const res = await request(app.instance) + .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 = container.get('automation.scriptRuntime'); + + await request(app.instance) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const scriptStopped = waitForEvent(scriptRuntime, AutomationEventType.scriptStopped); + + const stopRes = await request(app.instance).get('/automation/stop'); + expect(stopRes.status).toBe(200); + + await scriptStopped; + + const statusRes = await request(app.instance).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.instance).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 = 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(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 = container.get('automation.scriptRuntime'); + const MARKER = 'refresh-done'; + + await connectDevices(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 = container.get('automation.scriptRuntime'); + const MARKER = 'disconnect-done'; + + await connectDevices(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 = 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 = container.get('automation.scriptRuntime'); + const MARKER = 'start-done'; + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await request(app.instance) + .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/buttplugIoDevice.spec.ts b/tests/integration/buttplugIoDevice.spec.ts new file mode 100644 index 00000000..ac36733a --- /dev/null +++ b/tests/integration/buttplugIoDevice.spec.ts @@ -0,0 +1,205 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { AppInstance } from '../../src/app.js'; +import Device, { DeviceEvent } from '../../src/device/device.js'; +import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; +import ButtplugIoDevice from '../../src/device/protocol/buttplugIo/buttplugIoDevice.js'; +import ButtplugIoWebsocketDeviceProvider from '../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import WebSocketEvent from '../../src/device/webSocketEvent.js'; +import IntRangeDeviceAttribute from '../../src/device/attribute/intRangeDeviceAttribute.js'; +import BoolDeviceAttribute from '../../src/device/attribute/boolDeviceAttribute.js'; +import IntDeviceAttribute from '../../src/device/attribute/intDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../src/device/attribute/deviceAttribute.js'; +import { Int } from '../../src/util/numbers.js'; +import { ButtplugIoServerSimulator } from './helpers/buttplugIoServerSimulator.js'; +import { createTestApp, teardownTestApp } from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +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, + }, + }, + }, + }; +} + +function waitForDeviceConnected(container: Container, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const deviceManager = container.get('device.manager'); + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + reject(new Error(`Timed out waiting for device to connect (>${timeoutMs}ms)`)); + }, timeoutMs); + + const listener = (device: Device): void => { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(device as ButtplugIoDevice); + }; + + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); +} + +describe('ButtplugIo websocket provider', () => { + let simulator: ButtplugIoServerSimulator; + let app: AppInstance; + let container: Container; + let tmpDir: string; + let wsEmitSpy: ReturnType; + + beforeAll(async () => { + simulator = new ButtplugIoServerSimulator(); + const simulatorPort = await simulator.start(); + + simulator.addDevice({ + name: 'TestVibe', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }], + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100] }], + }); + + ({ app, container, tmpDir } = await createTestApp(makeButtplugSettings(simulatorPort))); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + await waitForDeviceConnected(container); + }); + + afterAll(async () => { + await teardownTestApp(app, container, tmpDir); + await simulator.stop(); + }); + + it('registers the device in the device manager', () => { + const devices = container.get('device.manager').getConnectedDevices(); + expect(devices).toHaveLength(1); + expect(devices[0]).toBeInstanceOf(ButtplugIoDevice); + }); + + it('exposes the device via GET /devices', async () => { + const res = await request(app.instance).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ + provider: ButtplugIoWebsocketDeviceProvider.providerName, + type: 'buttplugIo', + })); + }); + + it('emits deviceConnected WebSocket event when the device connects', () => { + expect(wsEmitSpy).toHaveBeenCalledWith( + WebSocketEvent.deviceConnected, + expect.objectContaining({ provider: ButtplugIoWebsocketDeviceProvider.providerName }), + ); + }); + + describe('device protocol', () => { + it('connects and parses all attribute types from the device list', async () => { + const deviceConnected = waitForDeviceConnected(container); + simulator.addDevice({ + name: 'MockDevice', + actuators: [ + { featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }, + { featureDescriptor: 'Switch', actuatorType: 'Oscillate', stepCount: 2 }, + ], + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100] }], + }); + const device = await deviceConnected; + + expect(device).toBeInstanceOf(ButtplugIoDevice); + + const vibe = await device.getAttribute('Vibrate-0') as IntRangeDeviceAttribute; + expect(vibe).toBeInstanceOf(IntRangeDeviceAttribute); + expect(vibe.modifier).toBe(DeviceAttributeModifier.writeOnly); + expect(vibe.min).toBe(0); + expect(vibe.max).toBe(20); + + const oscillate = await device.getAttribute('Oscillate-1'); + expect(oscillate).toBeInstanceOf(BoolDeviceAttribute); + expect(oscillate?.modifier).toBe(DeviceAttributeModifier.writeOnly); + + const pressure = await device.getAttribute('Pressure-0') as IntRangeDeviceAttribute; + expect(pressure).toBeInstanceOf(IntRangeDeviceAttribute); + expect(pressure.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(pressure.min).toBe(0); + expect(pressure.max).toBe(100); + }); + + it('picks up a device added via push after initial connection', async () => { + await new Promise(resolve => setTimeout(resolve, 200)); + + const deviceConnected = waitForDeviceConnected(container); + simulator.addDevice({ + name: 'LateDevice', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 10 }], + }); + + expect(await deviceConnected).toBeInstanceOf(ButtplugIoDevice); + }); + + it('setAttribute sends a ScalarCmd with the correct normalised scalar value', async () => { + simulator.receivedScalarCmds = []; + + const deviceConnected = waitForDeviceConnected(container); + simulator.addDevice({ + name: 'MockVibe', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }], + }); + const device = await deviceConnected; + + await device.setAttribute('Vibrate-0', Int.from(10)); + + 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); + }); + + it('polls sensor values automatically at the configured 100ms interval', async () => { + const deviceIndex = simulator.addDevice({ + name: 'MockSensor', + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100], reading: 77 }], + }); + + const device = await waitForDeviceConnected(container); + + simulator.setSensorReading(deviceIndex, 0, 77); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device refresh')), 1000); + device.on(DeviceEvent.deviceRefreshed, () => { clearTimeout(timeout); resolve(); }); + }); + + expect((await device.getAttribute('Pressure-0'))?.value).toBe(77); + }); + + it('refresh reads the current sensor value from the server', async () => { + const deviceIndex = simulator.addDevice({ + name: 'MockSensor', + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100], reading: 42 }], + }); + + const device = await waitForDeviceConnected(container); + + simulator.setSensorReading(deviceIndex, 0, 42); + await device.refresh(); + + expect((await device.getAttribute('Pressure-0') as IntDeviceAttribute).value).toBe(42); + }); + }); +}); diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts new file mode 100644 index 00000000..49b7c890 --- /dev/null +++ b/tests/integration/deviceEvents.spec.ts @@ -0,0 +1,146 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { AppInstance } from '../../src/app.js'; +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, +} from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; + +describe('Device events', () => { + let app: AppInstance; + let tmpDir: string; + let container: Container; + + beforeAll(async () => { + ({ app, container, tmpDir } = await createTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(app, container, tmpDir); + }); + + beforeEach(async () => { + await resetTestApp(app, container); + }); + + it('virtual device connected', async () => { + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const devices = 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(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0] as VirtualDevice; + + let observedValue: number | undefined; + let changedValue: number | undefined; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => 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(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = container.get('device.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + await connectDevices(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(container, [ + { id: TEST_DEVICE_ID, name: 'Test Random Generator' }, + { id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }, + ]); + + const deviceManager = container.get('device.manager'); + const settingsManager = 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(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = 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/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts new file mode 100644 index 00000000..39c21e8a --- /dev/null +++ b/tests/integration/helpers/appHelper.ts @@ -0,0 +1,146 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createApp, AppInstance, createContainer, AppOptions } from '../../../src/app.js'; +import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; +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, Pimple } from '@timesplinter/pimple'; +import { SlvCtrlPlusDeviceSimulator } from './slvCtrlPlusDeviceSimulator.js'; +import MockSerialPortFactory from './mockSerialPortFactory.js'; + +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 const createTestApp = async ( + settingsJson: object = baseSettingsJson +): Promise<{ app: AppInstance, container: Container, tmpDir: string, mockSerialPortFactory: MockSerialPortFactory }> => { + 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 app = createApp(container, options); + + return { app, container, tmpDir, mockSerialPortFactory }; +}; + +export const teardownTestApp = async (app: AppInstance, container: Container, tmpDir: string): Promise => { + const scriptRuntime = container.get('automation.scriptRuntime'); + if (scriptRuntime.isRunning()) { + await scriptRuntime.stop(); + } + + container.get('device.provider.loader').stopProviders(); + + await container.get('device.manager').reset(); + + fs.rmSync(tmpDir, { recursive: true }); +}; + +export const resetTestApp = async (app: AppInstance, container: Container): Promise => { + const deviceManager = container.get('device.manager'); + const scriptRuntime = container.get('automation.scriptRuntime'); + const settingsManager = 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 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..7c04436d --- /dev/null +++ b/tests/integration/helpers/buttplugIoServerSimulator.ts @@ -0,0 +1,274 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { createServer, IncomingMessage } from 'http'; + +/** + * Defines a mock actuator on a simulated Buttplug device. + */ +export interface MockActuator { + featureDescriptor: string; + actuatorType: string; + stepCount: number; +} + +/** + * Defines a mock sensor on a simulated Buttplug device. + * sensorRange is [min, max]. + */ +export interface MockSensor { + featureDescriptor: string; + sensorType: string; + 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[]; +} + +interface ButtplugMessage { + [type: string]: { Id: number; [key: string]: unknown }; +} + +/** + * 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 devices: Map = new Map(); + private nextDeviceIndex = 0; + + /** Recorded scalar commands: { deviceIndex, index, actuatorType, scalar }[] */ + public receivedScalarCmds: Array<{ + deviceIndex: number; + index: number; + actuatorType: string; + 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)); + }); + + 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 async stop(): Promise { + for (const ws of this.connectedClients) { + ws.close(); + } + this.connectedClients.clear(); + + await new Promise((resolve, reject) => { + this.wss?.close(err => (err ? reject(err) : resolve())); + }); + await new Promise((resolve, reject) => { + 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 { + let messages: ButtplugMessage[]; + try { + messages = JSON.parse(raw) as ButtplugMessage[]; + } catch { + return; + } + + for (const msg of messages) { + const type = Object.keys(msg)[0]; + if (undefined === type) continue; + const payload = msg[type]; + if (undefined === payload) continue; + + this.handleSingleMessage(ws, type, payload); + } + } + + private handleSingleMessage( + ws: WebSocket, + type: string, + payload: { Id: number; [key: string]: unknown } + ): void { + const id = payload.Id; + + switch (type) { + case 'RequestServerInfo': + ws.send( + `[{"ServerInfo":{"Id":${id},"MessageVersion":3,"MaxPingTime":0,"ServerName":"MockButtplugServer"}}]` + ); + break; + + case 'RequestDeviceList': { + const deviceList = [...this.devices.entries()].map(([idx, dev]) => + JSON.stringify(this.buildDeviceInfo(idx, dev)) + ); + ws.send( + `[{"DeviceList":{"Id":${id},"Devices":[${deviceList.join(',')}]}}]` + ); + break; + } + + case 'StartScanning': + ws.send(`[{"Ok":{"Id":${id}}}]`); + ws.send(`[{"ScanningFinished":{"Id":0}}]`); + break; + + case 'StopScanning': + ws.send(`[{"Ok":{"Id":${id}}}]`); + break; + + case 'ScalarCmd': { + const scalars = payload.Scalars as Array<{ + Index: number; + Scalar: number; + ActuatorType: string; + }>; + const deviceIndex = payload.DeviceIndex as number; + if (Array.isArray(scalars)) { + for (const s of scalars) { + this.receivedScalarCmds.push({ + deviceIndex, + index: s.Index, + actuatorType: s.ActuatorType, + scalar: s.Scalar, + }); + } + } + ws.send(`[{"Ok":{"Id":${id}}}]`); + break; + } + + case 'SensorReadCmd': { + const deviceIndex = payload.DeviceIndex as number; + const sensorIndex = payload.SensorIndex as number; + const sensorType = payload.SensorType as string; + const device = this.devices.get(deviceIndex); + const sensor = device?.sensors?.[sensorIndex]; + const reading = sensor?.reading ?? 0; + ws.send( + `[{"SensorReading":{"Id":${id},"DeviceIndex":${deviceIndex},"SensorIndex":${sensorIndex},"SensorType":"${sensorType}","Data":[${reading},0]}}]` + ); + break; + } + + case 'StopDeviceCmd': + case 'StopAllDevices': + case 'Ping': + ws.send(`[{"Ok":{"Id":${id}}}]`); + break; + + default: + ws.send( + `[{"Error":{"Id":${id},"ErrorMessage":"Unknown message: ${type}","ErrorCode":0}}]` + ); + } + } + + 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/mockSerialPortFactory.ts b/tests/integration/helpers/mockSerialPortFactory.ts new file mode 100644 index 00000000..006a6097 --- /dev/null +++ b/tests/integration/helpers/mockSerialPortFactory.ts @@ -0,0 +1,58 @@ +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'; +import { SlvCtrlPlusDeviceSimulator } from './slvCtrlPlusDeviceSimulator.js'; + +/** + * A SerialPortFactory that creates SerialPortMock instances backed by + * MockBinding instead of real hardware. + * + * Supports routing different port paths to different simulators via a Map. + * A single-simulator shorthand constructor is provided for backwards compatibility. + */ +export default class MockSerialPortFactory extends SerialPortFactory { + private readonly simulators: Map = new Map(); + + public constructor(simulator?: SlvCtrlPlusDeviceSimulator) { + super(); + + SerialPort.list = () => SerialPortMock.list(); + } + + public attachDevice(path: string, simulator: SlvCtrlPlusDeviceSimulator): void { + SerialPortMock.binding.createPort(path, { echo: false, record: false, vendorId: '1234', productId: '5678', manufacturer: 'NotArduino' }); // Arduino Uno + + 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/slvCtrlSerialDevice.spec.ts b/tests/integration/slvCtrlSerialDevice.spec.ts new file mode 100644 index 00000000..82ee85d9 --- /dev/null +++ b/tests/integration/slvCtrlSerialDevice.spec.ts @@ -0,0 +1,489 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { io as ioClient, Socket as ClientSocket } from 'socket.io-client'; +import { AppInstance } from '../../src/app.js'; +import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; +import Device from '../../src/device/device.js'; +import GenericSlvCtrlPlusDevice from '../../src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.js'; +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 } from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; +import MockSerialPortFactory from './helpers/mockSerialPortFactory.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: {}, + }, + }, +}; + +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); + }); +} + +function waitForDeviceDisconnected(container: Container, deviceId: string, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const deviceManager = container.get('device.manager'); + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + reject(new Error(`Timed out waiting for device ${deviceId} to disconnect (>${timeoutMs}ms)`)); + }, timeoutMs); + + const listener = (device: Device): void => { + if (device.getDeviceId === deviceId) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + resolve(); + } + }; + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); +} + +describe('SlvCtrl serial device provider', () => { + let app: AppInstance; + let container: Container; + let tmpDir: string; + let wsEmitSpy: ReturnType; + let mockSerialPortFactory: MockSerialPortFactory; + let v1Simulator: SlvCtrlPlusDeviceSimulator; + let legacySimulator: SlvCtrlPlusDeviceSimulator; + + beforeAll(async () => { + v1Simulator = new SlvCtrlPlusDeviceSimulator({ protocol: 'v1', deviceType: 'testDeviceV1' }); + legacySimulator = new SlvCtrlPlusDeviceSimulator({ protocol: 'legacy', deviceType: 'testDeviceLegacy' }); + + ({ app, container, tmpDir, mockSerialPortFactory } = await createTestApp(serialSettings)); + + mockSerialPortFactory.attachDevice(V1_PORT_PATH, v1Simulator); + mockSerialPortFactory.attachDevice(LEGACY_PORT_PATH, legacySimulator); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + const bothConnected = waitForNDevicesConnected(container, 2); + await bothConnected; + }); + + afterAll(async () => { + await teardownTestApp(app, container, tmpDir); + mockSerialPortFactory.reset(); + }); + + it('registers both devices in the device manager', () => { + const devices = container.get('device.manager').getConnectedDevices(); + expect(devices).toHaveLength(2); + expect(devices.every(d => d instanceof GenericSlvCtrlPlusDevice)).toBe(true); + }); + + it('exposes both devices via GET /devices', async () => { + const res = await request(app.instance).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + expect(res.body.items.every((item: { provider: string }) => + item.provider === SlvCtrlPlusSerialDeviceProvider.providerName + )).toBe(true); + }); + + it('emits deviceConnected WebSocket event for each device', () => { + const calls = wsEmitSpy.mock.calls.filter(([event]: [string, ...unknown[]]) => event === WebSocketEvent.deviceConnected); + expect(calls).toHaveLength(2); + }); + + it('deviceConnected WebSocket event includes serialized device with all attributes', () => { + const calls = wsEmitSpy.mock.calls.filter(([event]: [string, ...unknown[]]) => event === WebSocketEvent.deviceConnected); + expect(calls).toHaveLength(2); + + // Find the V1 device event + const v1DeviceEvent = calls.find(([, payload]: [string, any]) => + payload.deviceName?.includes('testDeviceV1') || payload.deviceModel === 'testDeviceV1' + ); + expect(v1DeviceEvent).toBeDefined(); + + const [, v1Payload] = v1DeviceEvent as [string, any]; + expect(v1Payload).toEqual(expect.objectContaining({ + deviceModel: 'testDeviceV1', + provider: SlvCtrlPlusSerialDeviceProvider.providerName, + attributes: expect.objectContaining({ + connected: expect.objectContaining({ type: 'bool', modifier: 'ro' }), + enabled: expect.objectContaining({ type: 'bool', modifier: 'rw' }), + counter: expect.objectContaining({ type: 'int', modifier: 'ro' }), + level: expect.objectContaining({ type: 'int', modifier: 'rw' }), + temperature: expect.objectContaining({ type: 'float', modifier: 'ro' }), + gain: expect.objectContaining({ type: 'float', modifier: 'rw' }), + label: expect.objectContaining({ type: 'str', modifier: 'ro' }), + mode: expect.objectContaining({ type: 'str', modifier: 'rw' }), + intensity: expect.objectContaining({ type: 'range', modifier: 'rw', min: 0, max: 100 }), + preset: expect.objectContaining({ type: 'list', modifier: 'rw' }), + channel: expect.objectContaining({ type: 'list', modifier: 'rw' }), + }) + })); + + // Find the legacy device event + const legacyDeviceEvent = calls.find(([, payload]: [string, any]) => + payload.deviceName?.includes('testDeviceLegacy') || payload.deviceModel === 'testDeviceLegacy' + ); + expect(legacyDeviceEvent).toBeDefined(); + + const [, legacyPayload] = legacyDeviceEvent as [string, any]; + expect(legacyPayload).toEqual(expect.objectContaining({ + deviceModel: 'testDeviceLegacy', + provider: SlvCtrlPlusSerialDeviceProvider.providerName, + attributes: expect.objectContaining({ + connected: expect.objectContaining({ type: 'bool', modifier: 'ro' }), + enabled: expect.objectContaining({ type: 'bool', modifier: 'rw' }), + counter: expect.objectContaining({ type: 'int', modifier: 'ro' }), + level: expect.objectContaining({ type: 'int', modifier: 'rw' }), + temperature: expect.objectContaining({ type: 'float', modifier: 'ro' }), + gain: expect.objectContaining({ type: 'float', modifier: 'rw' }), + label: expect.objectContaining({ type: 'str', modifier: 'ro' }), + mode: expect.objectContaining({ type: 'str', modifier: 'rw' }), + intensity: expect.objectContaining({ type: 'range', modifier: 'rw', min: 0, max: 100 }), + preset: expect.objectContaining({ type: 'list', modifier: 'rw' }), + channel: expect.objectContaining({ type: 'list', modifier: 'rw' }), + }) + })); + }); + + function getDevice(model: string): GenericSlvCtrlPlusDevice { + const devices = container.get('device.manager').getConnectedDevices() as GenericSlvCtrlPlusDevice[]; + const device = devices.find(d => d.getDeviceModel === model); + if (undefined === device) { + throw new Error(`Device with model '${model}' not found`); + } + return device; + } + + describe('V1 protocol device', () => { + it('discovers all attribute types with correct types and modifiers', async () => { + const device = getDevice('testDeviceV1'); + const res = await request(app.instance).get(`/device/${device.getDeviceId}`); + + expect(res.status).toBe(200); + const attributes = res.body.attributes; + + expect(attributes.connected).toEqual(expect.objectContaining({ type: 'bool', modifier: 'ro' })); + expect(attributes.enabled).toEqual(expect.objectContaining({ type: 'bool', modifier: 'rw' })); + + expect(attributes.counter).toEqual(expect.objectContaining({ type: 'int', modifier: 'ro' })); + expect(attributes.level).toEqual(expect.objectContaining({ type: 'int', modifier: 'rw' })); + + expect(attributes.temperature).toEqual(expect.objectContaining({ type: 'float', modifier: 'ro' })); + expect(attributes.gain).toEqual(expect.objectContaining({ type: 'float', modifier: 'rw' })); + + expect(attributes.label).toEqual(expect.objectContaining({ type: 'str', modifier: 'ro' })); + expect(attributes.mode).toEqual(expect.objectContaining({ type: 'str', modifier: 'rw' })); + + expect(attributes.intensity).toEqual(expect.objectContaining({ type: 'range', modifier: 'rw', min: 0, max: 100 })); + + // V1: preset is str list, channel is int list + expect(attributes.preset).toEqual(expect.objectContaining({ type: 'list', modifier: 'rw' })); + expect(attributes.channel).toEqual(expect.objectContaining({ type: 'list', modifier: 'rw' })); + }); + + it('updates all attribute values after refresh', async () => { + const device = getDevice('testDeviceV1'); + + v1Simulator.setValue('level', '7'); + v1Simulator.setValue('enabled', '1'); + v1Simulator.setValue('temperature', '98.6'); + + await device.refresh(); + + expect((await device.getAttribute('level'))?.value).toBe(7); + expect((await device.getAttribute('enabled'))?.value).toBe(true); + expect((await device.getAttribute('temperature'))?.value).toBeCloseTo(98.6); + }); + + it('setAttribute sends the correct serial command for each value type', async () => { + const device = getDevice('testDeviceV1'); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ level: 9 }).expect(202); + expect(v1Simulator.getValue('level')).toBe('9'); + expect((await device.getAttribute('level'))?.value).toBe(9); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ enabled: true }).expect(202); + expect(v1Simulator.getValue('enabled')).toBe('1'); + expect((await device.getAttribute('enabled'))?.value).toBe(true); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ mode: 'auto' }).expect(202); + expect(v1Simulator.getValue('mode')).toBe('auto'); + expect((await device.getAttribute('mode'))?.value).toBe('auto'); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ intensity: 50 }).expect(202); + expect(v1Simulator.getValue('intensity')).toBe('50'); + expect((await device.getAttribute('intensity'))?.value).toBe(50); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ preset: 'high' }).expect(202); + expect(v1Simulator.getValue('preset')).toBe('high'); + expect((await device.getAttribute('preset'))?.value).toBe('high'); + }); + + it('updates device attributes via WebSocket events', async () => { + const device = getDevice('testDeviceV1'); + const testPort = 13371; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Server failed to start')), 2000); + app.serve(testPort); + setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, 100); + }); + + const client = ioClient(`http://localhost:${testPort}`); + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Failed to connect WebSocket client')), 2000); + client.on('connect', () => { + clearTimeout(timeout); + resolve(); + }); + client.on('connect_error', reject); + }); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { level: 12 } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(v1Simulator.getValue('level')).toBe('12'); + expect((await device.getAttribute('level'))?.value).toBe(12); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { enabled: false } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(v1Simulator.getValue('enabled')).toBe('0'); + expect((await device.getAttribute('enabled'))?.value).toBe(false); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { intensity: 75 } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(v1Simulator.getValue('intensity')).toBe('75'); + expect((await device.getAttribute('intensity'))?.value).toBe(75); + } finally { + client.disconnect(); + } + }); + }); + + describe('legacy protocol device', () => { + it('discovers all attribute types with correct types and modifiers', async () => { + const device = getDevice('testDeviceLegacy'); + const res = await request(app.instance).get(`/device/${device.getDeviceId}`); + + expect(res.status).toBe(200); + const attributes = res.body.attributes; + + expect(attributes.connected).toEqual(expect.objectContaining({ type: 'bool', modifier: 'ro' })); + expect(attributes.enabled).toEqual(expect.objectContaining({ type: 'bool', modifier: 'rw' })); + + expect(attributes.counter).toEqual(expect.objectContaining({ type: 'int', modifier: 'ro' })); + expect(attributes.level).toEqual(expect.objectContaining({ type: 'int', modifier: 'rw' })); + + expect(attributes.temperature).toEqual(expect.objectContaining({ type: 'float', modifier: 'ro' })); + expect(attributes.gain).toEqual(expect.objectContaining({ type: 'float', modifier: 'rw' })); + + expect(attributes.label).toEqual(expect.objectContaining({ type: 'str', modifier: 'ro' })); + expect(attributes.mode).toEqual(expect.objectContaining({ type: 'str', modifier: 'rw' })); + + // Legacy: range is "0-100" syntax + expect(attributes.intensity).toEqual(expect.objectContaining({ type: 'range', modifier: 'rw', min: 0, max: 100 })); + + // Legacy: lists are plain string values + expect(attributes.preset).toEqual(expect.objectContaining({ type: 'list', modifier: 'rw' })); + expect(attributes.channel).toEqual(expect.objectContaining({ type: 'list', modifier: 'rw' })); + }); + + it('updates all attribute values after refresh', async () => { + const device = getDevice('testDeviceLegacy'); + + legacySimulator.setValue('level', '3'); + legacySimulator.setValue('enabled', '1'); + legacySimulator.setValue('temperature', '37.2'); + + await device.refresh(); + + expect((await device.getAttribute('level'))?.value).toBe(3); + expect((await device.getAttribute('enabled'))?.value).toBe(true); + expect((await device.getAttribute('temperature'))?.value).toBeCloseTo(37.2); + }); + + it('setAttribute sends the correct serial command for each value type', async () => { + const device = getDevice('testDeviceLegacy'); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ level: 4 }).expect(202); + expect(legacySimulator.getValue('level')).toBe('4'); + expect((await device.getAttribute('level'))?.value).toBe(4); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ enabled: true }).expect(202); + expect(legacySimulator.getValue('enabled')).toBe('1'); + expect((await device.getAttribute('enabled'))?.value).toBe(true); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ mode: 'auto' }).expect(202); + expect(legacySimulator.getValue('mode')).toBe('auto'); + expect((await device.getAttribute('mode'))?.value).toBe('auto'); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ intensity: 25 }).expect(202); + expect(legacySimulator.getValue('intensity')).toBe('25'); + expect((await device.getAttribute('intensity'))?.value).toBe(25); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ preset: 'medium' }).expect(202); + expect(legacySimulator.getValue('preset')).toBe('medium'); + expect((await device.getAttribute('preset'))?.value).toBe('medium'); + }); + + it('updates device attributes via WebSocket events', async () => { + const device = getDevice('testDeviceLegacy'); + const testPort = 13372; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Server failed to start')), 2000); + app.serve(testPort); + setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, 100); + }); + + const client = ioClient(`http://localhost:${testPort}`); + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Failed to connect WebSocket client')), 2000); + client.on('connect', () => { + clearTimeout(timeout); + resolve(); + }); + client.on('connect_error', reject); + }); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { level: 8 } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(legacySimulator.getValue('level')).toBe('8'); + expect((await device.getAttribute('level'))?.value).toBe(8); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { enabled: false } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(legacySimulator.getValue('enabled')).toBe('0'); + expect((await device.getAttribute('enabled'))?.value).toBe(false); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { preset: 'high' } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(legacySimulator.getValue('preset')).toBe('high'); + expect((await device.getAttribute('preset'))?.value).toBe('high'); + } finally { + client.disconnect(); + } + }); + }); + + describe('Device disconnection', () => { + let disconnectedV1DeviceId: string; + let disconnectedLegacyDeviceId: string; + + it('emits deviceDisconnected WebSocket event when device disconnects', async () => { + const device = getDevice('testDeviceV1'); + disconnectedV1DeviceId = device.getDeviceId; + + wsEmitSpy.mockClear(); + + // Disconnect the device - in production this would happen when hardware is unplugged + // and SerialPortObserver detects the missing port. MockBinding limitations prevent + // testing the full port-removal flow, but device.close() triggers the same events. + const disconnectPromise = waitForDeviceDisconnected(container, disconnectedV1DeviceId); + await device.close(); + await disconnectPromise; + + const calls = wsEmitSpy.mock.calls.filter(([event]: [string, ...unknown[]]) => event === WebSocketEvent.deviceDisconnected); + expect(calls).toHaveLength(1); + + const [, payload] = calls[0] as [string, any]; + expect(payload).toEqual(expect.objectContaining({ + deviceId: disconnectedV1DeviceId, + deviceModel: 'testDeviceV1', + provider: SlvCtrlPlusSerialDeviceProvider.providerName, + })); + }); + + it('removes disconnected device from GET /devices', async () => { + const devicesBeforeRes = await request(app.instance).get('/devices'); + expect(devicesBeforeRes.status).toBe(200); + const countBefore = devicesBeforeRes.body.count; + + const device = getDevice('testDeviceLegacy'); + disconnectedLegacyDeviceId = device.getDeviceId; + + const disconnectPromise = waitForDeviceDisconnected(container, disconnectedLegacyDeviceId); + await device.close(); + await disconnectPromise; + + const devicesAfterRes = await request(app.instance).get('/devices'); + expect(devicesAfterRes.status).toBe(200); + expect(devicesAfterRes.body.count).toBe(countBefore - 1); + + const deviceIds = devicesAfterRes.body.items.map((item: any) => item.deviceId); + expect(deviceIds).not.toContain(disconnectedLegacyDeviceId); + }); + + it('returns 404 for disconnected device on GET /device/:id', async () => { + const devices = container.get('device.manager').getConnectedDevices(); + expect(devices).toHaveLength(0); + + // Verify both disconnected devices return 404 + const v1Res = await request(app.instance).get(`/device/${disconnectedV1DeviceId}`); + expect(v1Res.status).toBe(404); + + const legacyRes = await request(app.instance).get(`/device/${disconnectedLegacyDeviceId}`); + expect(legacyRes.status).toBe(404); + }); + }); +}); 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..c900e288 --- /dev/null +++ b/tests/unit/controller/healthController.spec.ts @@ -0,0 +1,36 @@ +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, { HealthMetrics } from '../../../src/health/healthMetricsCollector.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/vitest.config.integration.ts b/vitest.config.integration.ts new file mode 100644 index 00000000..895346b4 --- /dev/null +++ b/vitest.config.integration.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./tests/vitest.setup.ts"], + include: ["tests/integration/**/*.spec.ts"], + }, +}); 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"],