From cab40d05c6252053608a9202731781c50f48bb64 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 13 Apr 2026 21:59:42 -0300 Subject: [PATCH] feat(v2): add BLE control backend, protocol, and control api scaffold --- README.md | 13 + package-lock.json | 1201 +++++++++++++++++++++- package.json | 1 + src/app/TyphonApplication.ts | 107 +- src/ble/AcInfinityProtocol.ts | 132 +++ src/ble/BleControllerClient.ts | 16 + src/ble/NodeBleControllerClient.ts | 212 ++++ src/config/AppConfig.ts | 106 +- src/domain/ControlTypes.ts | 37 + src/domain/RuleTypes.ts | 25 + src/http/ControlApiServer.ts | 198 ++++ src/metrics/TyphonMetrics.ts | 15 + src/services/BleControlBackend.ts | 266 +++++ src/services/BlePollingService.ts | 53 + src/services/ClimatePollingService.ts | 5 +- src/services/CloudControlBackend.ts | 69 ++ src/services/ControlApiError.ts | 9 + src/services/ControllerControlBackend.ts | 8 + src/services/RuleEngineService.ts | 47 + src/services/TelemetryCache.ts | 13 + src/ui/template.ts | 116 +++ tests/AcInfinityProtocol.test.ts | 51 + tests/AppConfig.test.ts | 51 + tests/BleControlBackend.test.ts | 97 ++ tests/ControlApiServer.test.ts | 131 +++ tests/RuleEngineService.test.ts | 21 + 26 files changed, 2951 insertions(+), 49 deletions(-) create mode 100644 src/ble/AcInfinityProtocol.ts create mode 100644 src/ble/BleControllerClient.ts create mode 100644 src/ble/NodeBleControllerClient.ts create mode 100644 src/domain/ControlTypes.ts create mode 100644 src/domain/RuleTypes.ts create mode 100644 src/http/ControlApiServer.ts create mode 100644 src/services/BleControlBackend.ts create mode 100644 src/services/BlePollingService.ts create mode 100644 src/services/CloudControlBackend.ts create mode 100644 src/services/ControlApiError.ts create mode 100644 src/services/ControllerControlBackend.ts create mode 100644 src/services/RuleEngineService.ts create mode 100644 src/services/TelemetryCache.ts create mode 100644 src/ui/template.ts create mode 100644 tests/AcInfinityProtocol.test.ts create mode 100644 tests/BleControlBackend.test.ts create mode 100644 tests/ControlApiServer.test.ts create mode 100644 tests/RuleEngineService.test.ts diff --git a/README.md b/README.md index 041aef3..bff991e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This implementation follows the same API conventions used by `keithah/homebridge ## Configuration Environment variables: +- `TYPHON_MODE` (optional, default `cloud`, values: `cloud|ble`) - `ACI_EMAIL` (required) - `ACI_PASSWORD` (required, use <= 25 chars for reliability) - `ACI_HOST` (optional, default `http://www.acinfinityserver.com`) @@ -32,6 +33,18 @@ Environment variables: - `REQUEST_TIMEOUT_MS` (optional, default `10000`) - `LISTEN_PORT` (optional, default `9108`) - `LOG_LEVEL` (optional, default `info`) +- `ENABLE_CONTROL_API` (optional, default `false`) +- `CONTROL_LISTEN_PORT` (optional, default `9110`) +- `TY_BLE_DEFAULT_MAC` (optional, default empty) +- `TY_BLE_ALLOWED_MACS` (optional, comma-delimited) +- `TY_BLE_DEVICE_TYPE` (optional, default `11` for Controller 69 Pro) +- `TY_BLE_SCAN_TIMEOUT_MS` (optional, default `20000`) +- `TY_BLE_PORT_BASE` (optional, `1` by default; set `0` if your controller expects zero-based port bytes) + +Notes: +- `TYPHON_MODE=cloud` keeps the existing v1 cloud exporter behavior. +- `TYPHON_MODE=ble` enables the v2 control API scaffolding and does not require `ACI_EMAIL`/`ACI_PASSWORD`. +- BLE mode uses BlueZ DBus (`node-ble`) and AC Infinity protocol packets for telemetry + per-port speed writes. Kubernetes runtime is expected to source `ACI_EMAIL`/`ACI_PASSWORD` from Vault. diff --git a/package-lock.json b/package-lock.json index 9c395cd..64ca777 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "typhon", "version": "0.1.0", "dependencies": { + "node-ble": "^1.13.0", "prom-client": "^15.1.3", "undici": "^7.16.0" }, @@ -1331,6 +1332,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nornagon/put": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz", + "integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==", + "license": "MIT/X11", + "engines": { + "node": ">=0.3.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -1494,6 +1504,30 @@ "dev": true, "license": "MIT" }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1514,7 +1548,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -1550,6 +1584,25 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1560,6 +1613,50 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT", + "optional": true + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1680,7 +1777,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -1696,6 +1793,26 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", @@ -1706,7 +1823,7 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1831,6 +1948,13 @@ ], "license": "CC-BY-4.0" }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1858,6 +1982,16 @@ "node": ">=10" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -1907,6 +2041,16 @@ "node": ">= 0.12.0" } }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -1934,13 +2078,33 @@ "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1948,6 +2112,13 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT", + "optional": true + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -1985,6 +2156,37 @@ "node": ">= 8" } }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dbus-next": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.10.2.tgz", + "integrity": "sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==", + "license": "MIT", + "dependencies": { + "@nornagon/put": "0.0.8", + "event-stream": "3.3.4", + "hexy": "^0.2.10", + "jsbi": "^2.0.5", + "long": "^4.0.0", + "safe-buffer": "^5.1.1", + "xml2js": "^0.4.17" + }, + "optionalDependencies": { + "usocket": "^0.3.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2028,6 +2230,23 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2048,6 +2267,23 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.335", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", @@ -2072,9 +2308,19 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2171,6 +2417,21 @@ "node": ">=4" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -2221,11 +2482,35 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "optional": true + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fb-watchman": { @@ -2238,6 +2523,13 @@ "bser": "2.1.1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2265,11 +2557,75 @@ "node": ">=8" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -2297,6 +2653,75 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "optional": true, + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "optional": true, + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2353,12 +2778,22 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -2379,7 +2814,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/handlebars": { @@ -2404,6 +2839,31 @@ "uglify-js": "^3.1.4" } }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2414,6 +2874,13 @@ "node": ">=8" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2427,6 +2894,15 @@ "node": ">= 0.4" } }, + "node_modules/hexy": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", + "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==", + "license": "MIT", + "bin": { + "hexy": "bin/hexy_cmd.js" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2434,6 +2910,22 @@ "dev": true, "license": "MIT" }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -2479,7 +2971,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -2490,7 +2982,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/is-arrayish": { @@ -2520,7 +3012,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -2559,13 +3051,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", + "optional": true + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT", + "optional": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", + "optional": true + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3283,6 +3796,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz", + "integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ==" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3303,6 +3828,27 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "optional": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3316,6 +3862,22 @@ "node": ">=6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3363,6 +3925,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3419,6 +3987,11 @@ "tmpl": "1.0.5" } }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3440,6 +4013,29 @@ "node": ">=8.6" } }, + "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==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -3454,7 +4050,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -3473,11 +4069,55 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -3493,6 +4133,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3507,6 +4154,56 @@ "dev": true, "license": "MIT" }, + "node_modules/node-ble": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/node-ble/-/node-ble-1.13.0.tgz", + "integrity": "sha512-jg8GmyZwowUcze6t/GEEunYvAcASR9hMUpRxs2jehmgdalDQ0xXtkmDfdAQO4Rq3b1gBxBRo43E/ASzbPf3d/A==", + "license": "MIT", + "dependencies": { + "dbus-next": "^0.10.2" + }, + "funding": { + "url": "https://github.com/sponsors/chrvadala" + } + }, + "node_modules/node-gyp": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz", + "integrity": "sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.3", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "request": "^2.88.2", + "rimraf": "^3.0.2", + "semver": "^7.3.2", + "tar": "^6.0.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -3521,6 +4218,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3544,11 +4257,55 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3658,7 +4415,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3681,6 +4438,25 @@ "dev": true, "license": "MIT" }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3752,6 +4528,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT", + "optional": true + }, "node_modules/prom-client": { "version": "15.1.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", @@ -3779,6 +4562,29 @@ "node": ">= 6" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -3796,6 +4602,16 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -3803,6 +4619,73 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT", + "optional": true + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3878,6 +4761,59 @@ "node": ">=10" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3888,6 +4824,13 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3915,7 +4858,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/sisteransi": { @@ -3956,6 +4899,18 @@ "source-map": "^0.6.0" } }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -3963,6 +4918,32 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -3976,6 +4957,32 @@ "node": ">=10" } }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT", + "optional": true + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -3994,7 +5001,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4009,7 +5016,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4077,6 +5084,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -4101,6 +5134,12 @@ "node": ">=8" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -4121,6 +5160,20 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/ts-jest": { "version": "29.4.9", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", @@ -4220,6 +5273,26 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", + "optional": true + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -4318,6 +5391,36 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/usocket": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/usocket/-/usocket-0.3.0.tgz", + "integrity": "sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ==", + "hasInstallScript": true, + "license": "ISC", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.14.2", + "node-gyp": "^7.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -4343,6 +5446,28 @@ "node": ">=10.12.0" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT", + "optional": true + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -4357,7 +5482,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4369,6 +5494,16 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -4398,7 +5533,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -4422,6 +5557,28 @@ "dev": true, "license": "MIT" }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 0ffa78f..8cc5366 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:ci": "mkdir -p build && jest --ci --runInBand --coverage --coverageReporters=text-summary --coverageReporters=cobertura --coverageReporters=json-summary" }, "dependencies": { + "node-ble": "^1.13.0", "prom-client": "^15.1.3", "undici": "^7.16.0" }, diff --git a/src/app/TyphonApplication.ts b/src/app/TyphonApplication.ts index e017ac9..5c2848e 100644 --- a/src/app/TyphonApplication.ts +++ b/src/app/TyphonApplication.ts @@ -1,45 +1,107 @@ import { AppConfig } from "../config/AppConfig"; import { AcInfinityApiClient } from "../http/AcInfinityApiClient"; +import { ControlApiServer } from "../http/ControlApiServer"; import { TyphonMetrics } from "../metrics/TyphonMetrics"; import { Logger } from "../observability/Logger"; +import { BleControlBackend } from "../services/BleControlBackend"; +import { BlePollingService } from "../services/BlePollingService"; import { ClimatePollingService } from "../services/ClimatePollingService"; +import { CloudControlBackend } from "../services/CloudControlBackend"; +import { ControllerControlBackend } from "../services/ControllerControlBackend"; +import { RuleEngineService } from "../services/RuleEngineService"; +import { TelemetryCache } from "../services/TelemetryCache"; import { MetricsServer } from "../transport/MetricsServer"; +interface PollingService { + start(): void; + stop(): void; +} + export class TyphonApplication { private readonly logger: Logger; - private readonly apiClient: AcInfinityApiClient; private readonly metrics: TyphonMetrics; - private readonly pollingService: ClimatePollingService; + private readonly telemetryCache: TelemetryCache; + private readonly apiClient: AcInfinityApiClient | null; + private readonly pollingService: PollingService | null; private readonly metricsServer: MetricsServer; + private readonly controlBackend: ControllerControlBackend; + private readonly ruleEngine: RuleEngineService; + private readonly controlApiServer: ControlApiServer | null; private shuttingDown = false; public constructor(private readonly config: AppConfig, version: string) { this.logger = new Logger(config.logLevel, "typhon"); - - this.apiClient = new AcInfinityApiClient( - config.aciHost, - config.aciEmail, - config.aciPassword, - config.requestTimeoutMs - ); - this.metrics = new TyphonMetrics(version); - this.pollingService = new ClimatePollingService( - this.apiClient, - this.metrics, - this.logger, - config.pollIntervalSeconds - ); + this.metrics.setRuntimeMode(config.mode); + this.telemetryCache = new TelemetryCache(); + this.ruleEngine = new RuleEngineService(); + + if (config.mode === "cloud") { + if (!config.aciEmail || !config.aciPassword) { + throw new Error("ACI_EMAIL and ACI_PASSWORD are required in cloud mode"); + } + this.apiClient = new AcInfinityApiClient( + config.aciHost, + config.aciEmail, + config.aciPassword, + config.requestTimeoutMs + ); + this.pollingService = new ClimatePollingService( + this.apiClient, + this.metrics, + this.logger, + config.pollIntervalSeconds, + (snapshot) => { + this.telemetryCache.setLatest(snapshot); + } + ); + this.controlBackend = new CloudControlBackend(this.telemetryCache); + } else { + this.apiClient = null; + this.pollingService = null; + const bleBackend = new BleControlBackend({ + defaultMac: config.bleDefaultMac, + allowedMacs: config.bleAllowedMacs, + requestTimeoutMs: config.requestTimeoutMs, + scanTimeoutMs: config.bleScanTimeoutMs, + deviceType: config.bleDeviceType, + portBase: config.blePortBase, + logger: this.logger + }); + this.pollingService = new BlePollingService( + bleBackend, + this.metrics, + this.logger, + config.pollIntervalSeconds + ); + this.controlBackend = bleBackend; + } + + this.controlApiServer = config.enableControlApi + ? new ControlApiServer( + config.controlListenPort, + this.controlBackend, + this.ruleEngine, + this.logger, + config.bleDefaultMac + ) + : null; this.metricsServer = new MetricsServer(config.listenPort, this.metrics, this.logger); } public async start(): Promise { this.installSignalHandlers(); await this.metricsServer.start(); - this.pollingService.start(); + this.pollingService?.start(); + if (this.controlApiServer) { + await this.controlApiServer.start(); + } this.logger.info("typhon started", { + mode: this.config.mode, poll_interval_seconds: this.config.pollIntervalSeconds, - listen_port: this.config.listenPort + listen_port: this.config.listenPort, + control_api_enabled: this.config.enableControlApi, + control_listen_port: this.config.controlListenPort }); } @@ -51,9 +113,14 @@ export class TyphonApplication { this.shuttingDown = true; this.logger.info("typhon shutting down"); - this.pollingService.stop(); + this.pollingService?.stop(); + if (this.controlApiServer) { + await this.controlApiServer.stop(); + } await this.metricsServer.stop(); - await this.apiClient.close(); + if (this.apiClient) { + await this.apiClient.close(); + } this.logger.info("typhon shutdown complete"); } diff --git a/src/ble/AcInfinityProtocol.ts b/src/ble/AcInfinityProtocol.ts new file mode 100644 index 0000000..776716c --- /dev/null +++ b/src/ble/AcInfinityProtocol.ts @@ -0,0 +1,132 @@ +export interface ParsedBleTelemetry { + temperatureCelsius: number; + temperatureFahrenheit: number; + humidityPercent: number; + vpdKpa: number; + choosePort: number; + workType: number; + fanSpeedGuess: number; +} + +function getBits(value: number, start: number, width: number): number { + return (value >> ((8 - start) - width)) & (255 >> (8 - width)); +} + +function getSignedShort(data: Buffer, offset: number): number { + const high = data[offset] ?? 0; + const low = data[offset + 1] ?? 0; + const combined = (low & 255) | ((high << 8) & 65280); + return combined > 32767 ? combined - 65536 : combined; +} + +function crc16CcittFalse(bytes: number[], start = 0, length = bytes.length): [number, number] { + let crc = 0xffff; + for (let index = start; index < start + length; index += 1) { + const value = bytes[index] ?? 0; + const b2 = (((crc << 8) | (crc >> 8)) & 0xffff) ^ (value & 0xff); + const b3 = b2 ^ ((b2 & 0xff) >> 4); + const b4 = b3 ^ ((b3 << 12) & 0xffff); + crc = b4 ^ (((b4 & 0xff) << 5) & 0xffff); + } + const finalValue = crc & 0xffff; + return [((finalValue >> 8) & 0xff), (finalValue & 0xff)]; +} + +export class AcInfinityProtocol { + private sequence = 1; + + public nextSequence(): number { + if (this.sequence >= 65535) { + this.sequence = 1; + return this.sequence; + } + this.sequence += 1; + return this.sequence; + } + + public buildGetModelData(deviceType: number, port: number): Buffer { + const command = [16, 17, 18, 19, 20, 21, 22, 23]; + if (this.supportsPortSelection(deviceType)) { + command.push(255, this.normalizePortByte(port)); + } + return this.addHead(command, 1, this.nextSequence()); + } + + public buildSetLevel( + deviceType: number, + workType: 1 | 2, + level: number, + port: number + ): Buffer { + if (level < 0 || level > 10 || !Number.isInteger(level)) { + throw new Error("level must be an integer between 0 and 10"); + } + + const command = [16, 1, workType, workType + 16, 1, level]; + if (this.supportsPortSelection(deviceType)) { + command.push(255, this.normalizePortByte(port)); + } + return this.addHead(command, 3, this.nextSequence()); + } + + public parseTelemetryNotification(data: Buffer): ParsedBleTelemetry { + if (data.length < 18 || data[0] !== 0x1e || data[1] !== 0xff) { + throw new Error("unexpected telemetry notification payload"); + } + + const temperatureCelsius = getSignedShort(data, 8) / 100; + const humidityPercent = getSignedShort(data, 10) / 100; + const vpdKpa = getSignedShort(data, 12) / 100; + const choosePort = getBits(data[7] ?? 0, 4, 4); + const workType = getBits(data[17] ?? 0, 4, 4); + const fanSpeedGuess = getBits(data[17] ?? 0, 0, 4); + + return { + temperatureCelsius, + temperatureFahrenheit: (temperatureCelsius * 9) / 5 + 32, + humidityPercent, + vpdKpa, + choosePort, + workType, + fanSpeedGuess + }; + } + + private supportsPortSelection(deviceType: number): boolean { + return deviceType === 7 || deviceType === 9 || deviceType === 11 || deviceType === 12; + } + + private normalizePortByte(port: number): number { + if (!Number.isInteger(port) || port < 0 || port > 255) { + throw new Error("port must be an integer between 0 and 255"); + } + return port; + } + + private addHead(command: number[], commandType: number, sequence: number): Buffer { + const result = new Array(command.length + 12).fill(0); + result[0] = 165; + result[1] = 0; + result[2] = (command.length >> 8) & 255; + result[3] = command.length & 255; + result[4] = (sequence >> 8) & 255; + result[5] = sequence & 255; + + const [crcA0, crcA1] = crc16CcittFalse(result, 0, 6); + result[6] = crcA0; + result[7] = crcA1; + + result[8] = 0; + result[9] = commandType; + + for (let i = 0; i < command.length; i += 1) { + result[10 + i] = command[i] ?? 0; + } + + const [crcB0, crcB1] = crc16CcittFalse(result, 8, command.length + 2); + result[10 + command.length] = crcB0; + result[11 + command.length] = crcB1; + + return Buffer.from(result); + } +} diff --git a/src/ble/BleControllerClient.ts b/src/ble/BleControllerClient.ts new file mode 100644 index 0000000..0945775 --- /dev/null +++ b/src/ble/BleControllerClient.ts @@ -0,0 +1,16 @@ +export interface BleTelemetryReading { + temperatureCelsius: number; + temperatureFahrenheit: number; + humidityPercent: number; + vpdKpa: number; + choosePort: number; + workType: number; + fanSpeedGuess: number; + receivedAtEpochSeconds: number; +} + +export interface BleControllerClient { + verifyConnection(macAddress: string): Promise; + readTelemetry(macAddress: string, requestPort: number): Promise; + setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise; +} diff --git a/src/ble/NodeBleControllerClient.ts b/src/ble/NodeBleControllerClient.ts new file mode 100644 index 0000000..ae751a0 --- /dev/null +++ b/src/ble/NodeBleControllerClient.ts @@ -0,0 +1,212 @@ +import NodeBle = require("node-ble"); + +import { Logger } from "../observability/Logger"; +import { AcInfinityProtocol } from "./AcInfinityProtocol"; +import { BleControllerClient, BleTelemetryReading } from "./BleControllerClient"; + +const READ_CHARACTERISTIC_UUIDS = [ + "70D51002-2C7F-4E75-AE8A-D758951CE4E0", + "0000ff02-0000-1000-8000-00805f9b34fb" +]; + +const WRITE_CHARACTERISTIC_UUIDS = [ + "70D51001-2C7F-4E75-AE8A-D758951CE4E0", + "0000ff01-0000-1000-8000-00805f9b34fb" +]; + +interface NodeBleClientOptions { + requestTimeoutMs: number; + scanTimeoutMs: number; + deviceType: number; + logger: Logger; +} + +interface ResolvedCharacteristics { + read: NodeBle.GattCharacteristic; + write: NodeBle.GattCharacteristic; +} + +export class NodeBleControllerClient implements BleControllerClient { + private readonly protocol = new AcInfinityProtocol(); + + public constructor(private readonly options: NodeBleClientOptions) {} + + public async verifyConnection(macAddress: string): Promise { + await this.withDevice(macAddress, async () => { + // no-op connection verification + }); + } + + public async readTelemetry(macAddress: string, requestPort: number): Promise { + return this.withDevice(macAddress, async (characteristics) => { + const command = this.protocol.buildGetModelData(this.options.deviceType, requestPort); + const wait = this.waitForNotification(characteristics.read, this.options.requestTimeoutMs); + await characteristics.write.writeValue(command, { type: "request" }); + const response = await wait; + const parsed = this.protocol.parseTelemetryNotification(response); + + return { + ...parsed, + receivedAtEpochSeconds: Math.floor(Date.now() / 1000) + }; + }); + } + + public async setPortSpeed( + macAddress: string, + portByte: number, + speedLevel: number + ): Promise { + await this.withDevice(macAddress, async (characteristics) => { + const command = this.protocol.buildSetLevel( + this.options.deviceType, + speedLevel > 0 ? 2 : 1, + speedLevel, + portByte + ); + + // Some controllers respond with a notify ack, some do not. + // We wait briefly but do not hard-fail if no notification arrives. + const wait = this.waitForNotification( + characteristics.read, + Math.min(3000, this.options.requestTimeoutMs) + ); + + await characteristics.write.writeValue(command, { type: "request" }); + try { + await wait; + } catch (error) { + this.options.logger.warn("ble set speed completed without notify ack", { + error_message: error instanceof Error ? error.message : String(error) + }); + } + }); + } + + private async withDevice( + macAddress: string, + action: (characteristics: ResolvedCharacteristics) => Promise + ): Promise { + const normalizedMac = macAddress.trim().toUpperCase(); + const { bluetooth, destroy } = NodeBle.createBluetooth(); + + let adapter: NodeBle.Adapter | null = null; + let discoveryStarted = false; + let device: NodeBle.Device | null = null; + let read: NodeBle.GattCharacteristic | null = null; + + try { + adapter = await bluetooth.defaultAdapter(); + const isDiscovering = await adapter.isDiscovering(); + if (!isDiscovering) { + await adapter.startDiscovery(); + discoveryStarted = true; + } + + this.options.logger.debug("waiting for ble device", { + mac_address: normalizedMac, + scan_timeout_ms: this.options.scanTimeoutMs + }); + + device = await adapter.waitDevice(normalizedMac, this.options.scanTimeoutMs); + await device.connect(); + + const gatt = await device.gatt(); + const resolved = await this.resolveCharacteristics(gatt); + read = resolved.read; + await read.startNotifications(); + + return await action(resolved); + } finally { + if (read) { + try { + await read.stopNotifications(); + } catch { + // noop + } + } + if (device) { + try { + await device.disconnect(); + } catch { + // noop + } + } + if (adapter && discoveryStarted) { + try { + await adapter.stopDiscovery(); + } catch { + // noop + } + } + destroy(); + } + } + + private async resolveCharacteristics(gatt: NodeBle.GattServer): Promise { + const serviceUuids = await gatt.services(); + + let read: NodeBle.GattCharacteristic | null = null; + let write: NodeBle.GattCharacteristic | null = null; + + for (const serviceUuid of serviceUuids) { + let service: NodeBle.GattService; + try { + service = await gatt.getPrimaryService(serviceUuid); + } catch { + continue; + } + + if (!read) { + read = await this.tryResolveCharacteristic(service, READ_CHARACTERISTIC_UUIDS); + } + if (!write) { + write = await this.tryResolveCharacteristic(service, WRITE_CHARACTERISTIC_UUIDS); + } + + if (read && write) { + return { read, write }; + } + } + + throw new Error("failed to resolve AC Infinity BLE characteristics"); + } + + private async tryResolveCharacteristic( + service: NodeBle.GattService, + uuids: string[] + ): Promise { + for (const uuid of uuids) { + try { + return await service.getCharacteristic(uuid); + } catch { + // try next uuid + } + } + return null; + } + + private async waitForNotification( + characteristic: NodeBle.GattCharacteristic, + timeoutMs: number + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for BLE notification after ${timeoutMs}ms`)); + }, timeoutMs); + + const onValue = (buffer: Buffer): void => { + cleanup(); + resolve(buffer); + }; + + const cleanup = (): void => { + clearTimeout(timer); + characteristic.removeListener("valuechanged", onValue); + }; + + characteristic.on("valuechanged", onValue); + }); + } +} diff --git a/src/config/AppConfig.ts b/src/config/AppConfig.ts index d91af3a..7ea68d8 100644 --- a/src/config/AppConfig.ts +++ b/src/config/AppConfig.ts @@ -1,35 +1,62 @@ import { DEFAULT_AC_INFINITY_HOST } from "../http/ApiConstants"; export type LogLevel = "debug" | "info" | "warn" | "error"; +export type TyphonMode = "cloud" | "ble"; + +const MAC_ADDRESS_RE = /^([0-9A-F]{2}:){5}[0-9A-F]{2}$/; export class AppConfig { public constructor( - public readonly aciEmail: string, - public readonly aciPassword: string, + public readonly mode: TyphonMode, + public readonly aciEmail: string | null, + public readonly aciPassword: string | null, public readonly aciHost: string, public readonly pollIntervalSeconds: number, public readonly listenPort: number, public readonly requestTimeoutMs: number, - public readonly logLevel: LogLevel + public readonly logLevel: LogLevel, + public readonly enableControlApi: boolean, + public readonly controlListenPort: number, + public readonly bleDefaultMac: string | null, + public readonly bleAllowedMacs: string[], + public readonly bleDeviceType: number, + public readonly bleScanTimeoutMs: number, + public readonly blePortBase: 0 | 1 ) {} public static fromEnv(env: NodeJS.ProcessEnv = process.env): AppConfig { - const aciEmail = this.getRequired(env, "ACI_EMAIL"); - const aciPassword = this.getRequired(env, "ACI_PASSWORD"); + const mode = this.parseMode(this.getOptional(env, "TYPHON_MODE", "cloud")); + const aciEmail = mode === "cloud" ? this.getRequired(env, "ACI_EMAIL") : null; + const aciPassword = mode === "cloud" ? this.getRequired(env, "ACI_PASSWORD") : null; const aciHost = this.getOptional(env, "ACI_HOST", DEFAULT_AC_INFINITY_HOST); const pollIntervalSeconds = this.parseNumber(env, "POLL_INTERVAL_SECONDS", 30, 5, 600); const listenPort = this.parseNumber(env, "LISTEN_PORT", 9108, 1, 65535); const requestTimeoutMs = this.parseNumber(env, "REQUEST_TIMEOUT_MS", 10000, 1000, 120000); const logLevel = this.parseLogLevel(this.getOptional(env, "LOG_LEVEL", "info")); + const enableControlApi = this.parseBoolean(env, "ENABLE_CONTROL_API", false); + const controlListenPort = this.parseNumber(env, "CONTROL_LISTEN_PORT", 9110, 1, 65535); + const bleDefaultMac = this.parseOptionalMac(this.getOptional(env, "TY_BLE_DEFAULT_MAC", "")); + const bleAllowedMacs = this.parseMacList(this.getOptional(env, "TY_BLE_ALLOWED_MACS", ""), bleDefaultMac); + const bleDeviceType = this.parseNumber(env, "TY_BLE_DEVICE_TYPE", 11, 1, 255); + const bleScanTimeoutMs = this.parseNumber(env, "TY_BLE_SCAN_TIMEOUT_MS", 20000, 1000, 120000); + const blePortBase = this.parsePortBase(this.getOptional(env, "TY_BLE_PORT_BASE", "1")); return new AppConfig( + mode, aciEmail, aciPassword, aciHost, pollIntervalSeconds, listenPort, requestTimeoutMs, - logLevel + logLevel, + enableControlApi, + controlListenPort, + bleDefaultMac, + bleAllowedMacs, + bleDeviceType, + bleScanTimeoutMs, + blePortBase ); } @@ -74,4 +101,71 @@ export class AppConfig { } throw new Error(`LOG_LEVEL must be one of: debug, info, warn, error`); } + + private static parseMode(value: string): TyphonMode { + if (value === "cloud" || value === "ble") { + return value; + } + throw new Error("TYPHON_MODE must be one of: cloud, ble"); + } + + private static parseBoolean( + env: NodeJS.ProcessEnv, + key: string, + fallback: boolean + ): boolean { + const raw = env[key]; + if (!raw || raw.trim().length === 0) { + return fallback; + } + + const normalized = raw.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no") { + return false; + } + throw new Error(`${key} must be a boolean`); + } + + private static parseOptionalMac(value: string): string | null { + const normalized = value.trim().toUpperCase(); + if (normalized.length === 0) { + return null; + } + if (!MAC_ADDRESS_RE.test(normalized)) { + throw new Error("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF"); + } + return normalized; + } + + private static parseMacList(value: string, bleDefaultMac: string | null): string[] { + const parsed = value + .split(",") + .map((part) => part.trim().toUpperCase()) + .filter((part) => part.length > 0); + + for (const mac of parsed) { + if (!MAC_ADDRESS_RE.test(mac)) { + throw new Error("TY_BLE_ALLOWED_MACS must contain MAC addresses like AA:BB:CC:DD:EE:FF"); + } + } + + if (bleDefaultMac && !parsed.includes(bleDefaultMac)) { + parsed.push(bleDefaultMac); + } + + return parsed; + } + + private static parsePortBase(value: string): 0 | 1 { + if (value === "0") { + return 0; + } + if (value === "1") { + return 1; + } + throw new Error("TY_BLE_PORT_BASE must be 0 or 1"); + } } diff --git a/src/domain/ControlTypes.ts b/src/domain/ControlTypes.ts new file mode 100644 index 0000000..10f6dd3 --- /dev/null +++ b/src/domain/ControlTypes.ts @@ -0,0 +1,37 @@ +import type { TyphonMode } from "../config/AppConfig"; + +export interface ControlCapabilities { + pairing: boolean; + portControl: boolean; + advancedRules: boolean; +} + +export interface ControlPortState { + port: number; + name: string; + fanGroup: string; + currentSpeedLevel: number | null; + online: boolean | null; + powerState: boolean | null; +} + +export interface ControllerControlStatus { + mode: TyphonMode; + backend: string; + controllerMac: string | null; + connected: boolean; + paired: boolean; + telemetrySource: "cloud" | "ble"; + lastSnapshotEpochSeconds: number | null; + capabilities: ControlCapabilities; + ports: ControlPortState[]; + notes: string[]; +} + +export interface PairRequest { + macAddress: string; +} + +export interface SetPortSpeedRequest { + speedLevel: number; +} diff --git a/src/domain/RuleTypes.ts b/src/domain/RuleTypes.ts new file mode 100644 index 0000000..7d7e982 --- /dev/null +++ b/src/domain/RuleTypes.ts @@ -0,0 +1,25 @@ +export type RuleMode = "manual" | "climate_auto"; + +export interface RuleSet { + mode: RuleMode; + minimumHoldSeconds: number; + temperatureTargetC: number; + temperatureBandC: number; + humidityTargetPercent: number; + humidityBandPercent: number; + updatedAtEpochSeconds: number; +} + +export type RuleSetUpdate = Partial>; + +export function defaultRuleSet(nowEpochSeconds = Math.floor(Date.now() / 1000)): RuleSet { + return { + mode: "manual", + minimumHoldSeconds: 60, + temperatureTargetC: 24, + temperatureBandC: 0.5, + humidityTargetPercent: 45, + humidityBandPercent: 3, + updatedAtEpochSeconds: nowEpochSeconds + }; +} diff --git a/src/http/ControlApiServer.ts b/src/http/ControlApiServer.ts new file mode 100644 index 0000000..f761d91 --- /dev/null +++ b/src/http/ControlApiServer.ts @@ -0,0 +1,198 @@ +import http, { IncomingMessage } from "node:http"; + +import { Logger } from "../observability/Logger"; +import { ControlApiError } from "../services/ControlApiError"; +import { ControllerControlBackend } from "../services/ControllerControlBackend"; +import { RuleEngineService } from "../services/RuleEngineService"; +import { renderControlUi } from "../ui/template"; + +interface JsonObject { + [key: string]: unknown; +} + +export class ControlApiServer { + private server: http.Server | null = null; + + public constructor( + private readonly port: number, + private readonly backend: ControllerControlBackend, + private readonly rules: RuleEngineService, + private readonly logger: Logger, + private readonly defaultMac: string | null + ) {} + + public async start(): Promise { + this.server = http.createServer(async (req, res) => { + try { + await this.handle(req, res); + } catch (error) { + this.handleError(error, res); + } + }); + + await new Promise((resolve) => { + this.server?.listen(this.port, () => { + this.logger.info("control api server started", { port: this.port }); + resolve(); + }); + }); + } + + public async stop(): Promise { + if (!this.server) { + return; + } + + await new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + this.server = null; + } + + private async handle(req: IncomingMessage, res: http.ServerResponse): Promise { + const method = req.method ?? "GET"; + const url = new URL(req.url ?? "/", "http://localhost"); + + if (url.pathname === "/healthz") { + this.json(res, 200, { ok: true }); + return; + } + + if (url.pathname === "/ui" || url.pathname === "/") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderControlUi(this.defaultMac)); + return; + } + + if (method === "GET" && url.pathname === "/api/v2/status") { + const status = await this.backend.getStatus(); + this.json(res, 200, { + ...status, + defaultMac: this.defaultMac + }); + return; + } + + if (method === "GET" && url.pathname === "/api/v2/ports") { + const ports = await this.backend.getPorts(); + this.json(res, 200, { ports }); + return; + } + + if (method === "POST" && url.pathname === "/api/v2/pair") { + const body = await this.readJson(req); + const macFromBody = this.extractString(body, "macAddress") ?? this.extractString(body, "mac_address"); + const mac = macFromBody ?? this.defaultMac; + if (!mac) { + throw new ControlApiError("mac address is required", 400); + } + const status = await this.backend.pair(mac); + this.json(res, 200, status); + return; + } + + const speedMatch = url.pathname.match(/^\/api\/v2\/ports\/(\d+)\/speed$/); + if (method === "POST" && speedMatch?.[1]) { + const port = Number(speedMatch[1]); + const body = await this.readJson(req); + const speedValue = this.extractNumber(body, "speedLevel") ?? this.extractNumber(body, "speed_level"); + if (speedValue === null) { + throw new ControlApiError("speed_level is required", 400); + } + await this.backend.setPortSpeed(port, speedValue); + this.json(res, 200, { ok: true }); + return; + } + + if (method === "GET" && url.pathname === "/api/v2/rules") { + this.json(res, 200, { rules: this.rules.getRules() }); + return; + } + + if (method === "POST" && url.pathname === "/api/v2/rules") { + const body = await this.readJson(req); + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new ControlApiError("rules body must be an object", 400); + } + const updated = this.rules.updateRules(body); + this.json(res, 200, { rules: updated }); + return; + } + + this.json(res, 404, { error: "not found" }); + } + + private async readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + const maxBytes = 64 * 1024; + + for await (const chunk of req) { + const asBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += asBuffer.length; + if (totalBytes > maxBytes) { + throw new ControlApiError("request body too large", 413); + } + chunks.push(asBuffer); + } + + if (chunks.length === 0) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new ControlApiError("request body must be valid JSON", 400); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ControlApiError("request body must be a JSON object", 400); + } + + return parsed as JsonObject; + } + + private extractString(body: JsonObject, key: string): string | null { + const value = body[key]; + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + private extractNumber(body: JsonObject, key: string): number | null { + const value = body[key]; + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + return value; + } + + private json(res: http.ServerResponse, statusCode: number, payload: object): void { + res.statusCode = statusCode; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(payload)); + } + + private handleError(error: unknown, res: http.ServerResponse): void { + if (error instanceof ControlApiError) { + this.json(res, error.statusCode, { error: error.message }); + return; + } + + this.logger.error("control api request failed", { + error_message: error instanceof Error ? error.message : String(error) + }); + this.json(res, 500, { error: "internal server error" }); + } +} diff --git a/src/metrics/TyphonMetrics.ts b/src/metrics/TyphonMetrics.ts index 6d2fa0d..c9770e3 100644 --- a/src/metrics/TyphonMetrics.ts +++ b/src/metrics/TyphonMetrics.ts @@ -9,6 +9,7 @@ import { AcInfinityMode, ClimateSnapshot } from "../domain/ClimateSnapshot"; +import type { TyphonMode } from "../config/AppConfig"; const MODE_LABELS: readonly AcInfinityMode[] = [ AcInfinityMode.Off, @@ -49,6 +50,7 @@ export class TyphonMetrics { private readonly modeGauge: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group" | "mode">; private readonly buildInfo: Gauge<"version">; + private readonly runtimeMode: Gauge<"mode">; private readonly resettable: Array< Gauge<"controller_id" | "controller_name"> | @@ -180,6 +182,12 @@ export class TyphonMetrics { labelNames: ["version"], registers: [this.registry] }); + this.runtimeMode = new Gauge({ + name: "typhon_runtime_mode", + help: "Typhon runtime mode (one-hot by mode label)", + labelNames: ["mode"], + registers: [this.registry] + }); this.resettable = [ this.controllerOnline, @@ -201,6 +209,8 @@ export class TyphonMetrics { this.exporterUp.set(0); this.dataAgeSeconds.set(0); this.buildInfo.labels(version).set(1); + this.runtimeMode.labels("cloud").set(1); + this.runtimeMode.labels("ble").set(0); } public getRegistry(): Registry { @@ -279,4 +289,9 @@ export class TyphonMetrics { } this.dataAgeSeconds.set(Math.max(0, nowEpochSeconds - this.lastSuccessEpoch)); } + + public setRuntimeMode(mode: TyphonMode): void { + this.runtimeMode.labels("cloud").set(mode === "cloud" ? 1 : 0); + this.runtimeMode.labels("ble").set(mode === "ble" ? 1 : 0); + } } diff --git a/src/services/BleControlBackend.ts b/src/services/BleControlBackend.ts new file mode 100644 index 0000000..fc12ad2 --- /dev/null +++ b/src/services/BleControlBackend.ts @@ -0,0 +1,266 @@ +import { AcInfinityMode, ClimateSnapshot, ControllerClimate, PortClimate } from "../domain/ClimateSnapshot"; +import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes"; +import { Logger } from "../observability/Logger"; +import { BleControllerClient, BleTelemetryReading } from "../ble/BleControllerClient"; +import { NodeBleControllerClient } from "../ble/NodeBleControllerClient"; +import { ControlApiError } from "./ControlApiError"; +import { ControllerControlBackend } from "./ControllerControlBackend"; + +const MAC_ADDRESS_RE = /^([0-9A-F]{2}:){5}[0-9A-F]{2}$/; + +interface BleControlBackendOptions { + defaultMac: string | null; + allowedMacs: string[]; + requestTimeoutMs: number; + scanTimeoutMs: number; + deviceType: number; + portBase: 0 | 1; + logger: Logger; + client?: BleControllerClient; +} + +export class BleControlBackend implements ControllerControlBackend { + private pairedMac: string | null; + private connected = false; + private readonly portSpeeds = new Map(); + private readonly allowedMacs: Set; + private readonly client: BleControllerClient; + private readonly logger: Logger; + private readonly portBase: 0 | 1; + + private lastTelemetry: BleTelemetryReading | null = null; + private lastError: string | null = null; + + private queue: Promise = Promise.resolve(); + + public constructor(options: BleControlBackendOptions) { + this.pairedMac = options.defaultMac; + this.allowedMacs = new Set(options.allowedMacs); + this.logger = options.logger; + this.portBase = options.portBase; + this.client = options.client ?? new NodeBleControllerClient({ + requestTimeoutMs: options.requestTimeoutMs, + scanTimeoutMs: options.scanTimeoutMs, + deviceType: options.deviceType, + logger: options.logger + }); + + for (let port = 1; port <= 4; port += 1) { + this.portSpeeds.set(port, 0); + } + } + + public async getStatus(): Promise { + const notes: string[] = [ + "BLE backend active via BlueZ DBus.", + "Per-port writes use AC Infinity protocol with port selector bytes." + ]; + + if (this.lastError) { + notes.push(`last_error=${this.lastError}`); + } + + return { + mode: "ble", + backend: "ble-node-bluez", + controllerMac: this.pairedMac, + connected: this.connected, + paired: this.pairedMac !== null, + telemetrySource: "ble", + lastSnapshotEpochSeconds: this.lastTelemetry?.receivedAtEpochSeconds ?? null, + capabilities: { + pairing: true, + portControl: true, + advancedRules: true + }, + ports: await this.getPorts(), + notes + }; + } + + public async pair(macAddress: string): Promise { + const normalized = this.normalizeMac(macAddress); + this.assertAllowedMac(normalized); + + await this.synchronized(async () => { + this.logger.info("attempting ble pair/connect", { mac_address: normalized }); + try { + await this.client.verifyConnection(normalized); + this.pairedMac = normalized; + this.connected = true; + this.lastError = null; + } catch (error) { + this.connected = false; + this.lastError = error instanceof Error ? error.message : String(error); + throw new ControlApiError(`failed to pair/connect over BLE: ${this.lastError}`, 502); + } + }); + + return this.getStatus(); + } + + public async getPorts(): Promise { + const ports: ControlPortState[] = []; + for (let port = 1; port <= 4; port += 1) { + const speed = this.portSpeeds.get(port) ?? 0; + ports.push({ + port, + name: `Port ${port}`, + fanGroup: this.defaultFanGroup(port), + currentSpeedLevel: speed, + online: this.connected, + powerState: speed > 0 + }); + } + return ports; + } + + public async setPortSpeed(port: number, speedLevel: number): Promise { + if (!Number.isInteger(port) || port < 1 || port > 4) { + throw new ControlApiError("port must be an integer between 1 and 4", 400); + } + if (!Number.isInteger(speedLevel) || speedLevel < 0 || speedLevel > 10) { + throw new ControlApiError("speed_level must be an integer between 0 and 10", 400); + } + + const mac = this.requireMac(); + + await this.synchronized(async () => { + this.logger.debug("setting ble fan speed", { port, speed_level: speedLevel }); + try { + await this.client.setPortSpeed(mac, this.toDevicePort(port), speedLevel); + this.portSpeeds.set(port, speedLevel); + this.connected = true; + this.lastError = null; + } catch (error) { + this.connected = false; + this.lastError = error instanceof Error ? error.message : String(error); + throw new ControlApiError(`failed to set BLE fan speed: ${this.lastError}`, 502); + } + }); + } + + public async refreshTelemetry(): Promise { + const mac = this.requireMac(); + + await this.synchronized(async () => { + try { + // 0 requests controller-level telemetry regardless of active port selection. + const telemetry = await this.client.readTelemetry(mac, 0); + this.lastTelemetry = telemetry; + this.connected = true; + this.lastError = null; + + const derivedPort = this.fromDevicePort(telemetry.choosePort); + if (derivedPort >= 1 && derivedPort <= 4 && telemetry.fanSpeedGuess >= 0 && telemetry.fanSpeedGuess <= 10) { + this.portSpeeds.set(derivedPort, telemetry.fanSpeedGuess); + } + } catch (error) { + this.connected = false; + this.lastError = error instanceof Error ? error.message : String(error); + throw new ControlApiError(`failed to read BLE telemetry: ${this.lastError}`, 502); + } + }); + + return this.buildSnapshot(); + } + + public buildSnapshot(nowEpochSeconds = Math.floor(Date.now() / 1000)): ClimateSnapshot { + const reading = this.lastTelemetry; + const controller = new ControllerClimate( + this.pairedMac ?? "ble-controller", + "AC Infinity 69 Pro", + this.connected, + reading?.temperatureCelsius ?? 0, + (reading?.humidityPercent ?? 0) / 100, + reading?.vpdKpa ?? 0, + 11, + true, + this.buildPortSnapshots() + ); + + return new ClimateSnapshot(nowEpochSeconds, [controller]); + } + + private buildPortSnapshots(): PortClimate[] { + const ports: PortClimate[] = []; + for (let port = 1; port <= 4; port += 1) { + const speed = this.portSpeeds.get(port) ?? 0; + const mode = speed > 0 ? AcInfinityMode.On : AcInfinityMode.Off; + ports.push( + new PortClimate( + port, + `Port ${port}`, + this.defaultFanGroup(port), + this.connected, + speed > 0, + speed, + speed, + 0, + true, + null, + mode + ) + ); + } + return ports; + } + + private requireMac(): string { + if (!this.pairedMac) { + throw new ControlApiError("controller mac address is not paired; run /api/v2/pair first", 412); + } + return this.pairedMac; + } + + private normalizeMac(value: string): string { + const normalized = value.trim().toUpperCase(); + if (!MAC_ADDRESS_RE.test(normalized)) { + throw new ControlApiError("mac address must look like AA:BB:CC:DD:EE:FF", 400); + } + return normalized; + } + + private assertAllowedMac(macAddress: string): void { + if (this.allowedMacs.size > 0 && !this.allowedMacs.has(macAddress)) { + throw new ControlApiError("mac address is not in TY_BLE_ALLOWED_MACS", 403); + } + } + + private defaultFanGroup(port: number): string { + if (port === 1) { + return "outlet"; + } + if (port === 2) { + return "inside_inlet"; + } + if (port === 3) { + return "outside_inlet"; + } + return "interior"; + } + + private toDevicePort(port: number): number { + return port - this.portBase; + } + + private fromDevicePort(portByte: number): number { + return portByte + this.portBase; + } + + private async synchronized(task: () => Promise): Promise { + const previous = this.queue; + let release: (() => void) | undefined; + this.queue = new Promise((resolve) => { + release = resolve; + }); + + await previous; + + try { + return await task(); + } finally { + release?.(); + } + } +} diff --git a/src/services/BlePollingService.ts b/src/services/BlePollingService.ts new file mode 100644 index 0000000..222f66e --- /dev/null +++ b/src/services/BlePollingService.ts @@ -0,0 +1,53 @@ +import { TyphonMetrics } from "../metrics/TyphonMetrics"; +import { Logger } from "../observability/Logger"; +import { BleControlBackend } from "./BleControlBackend"; + +export class BlePollingService { + private timer: NodeJS.Timeout | null = null; + private inFlight = false; + + public constructor( + private readonly backend: BleControlBackend, + private readonly metrics: TyphonMetrics, + private readonly logger: Logger, + private readonly pollIntervalSeconds: number + ) {} + + public start(): void { + void this.pollOnce(); + this.timer = setInterval(() => { + void this.pollOnce(); + }, this.pollIntervalSeconds * 1000); + } + + public stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async pollOnce(): Promise { + if (this.inFlight) { + this.logger.warn("skipping ble poll because previous poll is still running"); + return; + } + + this.inFlight = true; + try { + const snapshot = await this.backend.refreshTelemetry(); + this.metrics.updateFromSnapshot(snapshot); + this.logger.info("ble poll succeeded", { + controller_count: snapshot.controllers.length + }); + } catch (error) { + this.metrics.markPollFailure("ble", "ble_error"); + this.logger.error("ble poll failed", { + error_message: error instanceof Error ? error.message : String(error) + }); + } finally { + this.metrics.refreshDataAgeGauge(); + this.inFlight = false; + } + } +} diff --git a/src/services/ClimatePollingService.ts b/src/services/ClimatePollingService.ts index 9abe769..169b7b8 100644 --- a/src/services/ClimatePollingService.ts +++ b/src/services/ClimatePollingService.ts @@ -1,6 +1,7 @@ import { AcInfinityApiClient, AcInfinityApiError } from "../http/AcInfinityApiClient"; import { TyphonMetrics } from "../metrics/TyphonMetrics"; import { Logger } from "../observability/Logger"; +import { ClimateSnapshot } from "../domain/ClimateSnapshot"; export class ClimatePollingService { private timer: NodeJS.Timeout | null = null; @@ -10,7 +11,8 @@ export class ClimatePollingService { private readonly client: AcInfinityApiClient, private readonly metrics: TyphonMetrics, private readonly logger: Logger, - private readonly pollIntervalSeconds: number + private readonly pollIntervalSeconds: number, + private readonly onSnapshot?: (snapshot: ClimateSnapshot) => void ) {} public start(): void { @@ -37,6 +39,7 @@ export class ClimatePollingService { try { const snapshot = await this.client.fetchSnapshot(); this.metrics.updateFromSnapshot(snapshot); + this.onSnapshot?.(snapshot); this.logger.info("poll succeeded", { controller_count: snapshot.controllers.length }); diff --git a/src/services/CloudControlBackend.ts b/src/services/CloudControlBackend.ts new file mode 100644 index 0000000..90b3758 --- /dev/null +++ b/src/services/CloudControlBackend.ts @@ -0,0 +1,69 @@ +import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes"; +import { ControlApiError } from "./ControlApiError"; +import { ControllerControlBackend } from "./ControllerControlBackend"; +import { TelemetryCache } from "./TelemetryCache"; + +export class CloudControlBackend implements ControllerControlBackend { + public constructor(private readonly cache: TelemetryCache) {} + + public async getStatus(): Promise { + const latest = this.cache.getLatest(); + const firstController = latest?.controllers[0] ?? null; + const connected = latest?.controllers.some((controller) => controller.online) ?? false; + + return { + mode: "cloud", + backend: "cloud-api", + controllerMac: null, + connected, + paired: false, + telemetrySource: "cloud", + lastSnapshotEpochSeconds: latest?.collectedAtEpochSeconds ?? null, + capabilities: { + pairing: false, + portControl: false, + advancedRules: true + }, + ports: this.mapPorts(firstController?.ports ?? []), + notes: [ + "Control write APIs are disabled in cloud mode during v2 buildout." + ] + }; + } + + public async pair(): Promise { + throw new ControlApiError("pairing is not supported in cloud mode", 409); + } + + public async getPorts(): Promise { + const latest = this.cache.getLatest(); + const firstController = latest?.controllers[0] ?? null; + return this.mapPorts(firstController?.ports ?? []); + } + + public async setPortSpeed(): Promise { + throw new ControlApiError("per-port speed writes are not supported in cloud mode", 409); + } + + private mapPorts( + ports: Array<{ + port: number; + name: string; + fanGroup: string; + currentSpeedLevel: number; + online: boolean; + powerState: boolean; + }> + ): ControlPortState[] { + return ports + .map((port) => ({ + port: port.port, + name: port.name, + fanGroup: port.fanGroup, + currentSpeedLevel: port.currentSpeedLevel, + online: port.online, + powerState: port.powerState + })) + .sort((a, b) => a.port - b.port); + } +} diff --git a/src/services/ControlApiError.ts b/src/services/ControlApiError.ts new file mode 100644 index 0000000..d913ed5 --- /dev/null +++ b/src/services/ControlApiError.ts @@ -0,0 +1,9 @@ +export class ControlApiError extends Error { + public constructor( + message: string, + public readonly statusCode: number + ) { + super(message); + this.name = "ControlApiError"; + } +} diff --git a/src/services/ControllerControlBackend.ts b/src/services/ControllerControlBackend.ts new file mode 100644 index 0000000..84750e0 --- /dev/null +++ b/src/services/ControllerControlBackend.ts @@ -0,0 +1,8 @@ +import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes"; + +export interface ControllerControlBackend { + getStatus(): Promise; + pair(macAddress: string): Promise; + getPorts(): Promise; + setPortSpeed(port: number, speedLevel: number): Promise; +} diff --git a/src/services/RuleEngineService.ts b/src/services/RuleEngineService.ts new file mode 100644 index 0000000..e5d66dc --- /dev/null +++ b/src/services/RuleEngineService.ts @@ -0,0 +1,47 @@ +import { RuleSet, RuleSetUpdate, defaultRuleSet } from "../domain/RuleTypes"; +import { ControlApiError } from "./ControlApiError"; + +export class RuleEngineService { + private ruleSet: RuleSet; + + public constructor(initialRuleSet = defaultRuleSet()) { + this.ruleSet = initialRuleSet; + } + + public getRules(): RuleSet { + return { ...this.ruleSet }; + } + + public updateRules(update: RuleSetUpdate, nowEpochSeconds = Math.floor(Date.now() / 1000)): RuleSet { + const merged: RuleSet = { + ...this.ruleSet, + ...update, + updatedAtEpochSeconds: nowEpochSeconds + }; + + this.assertValid(merged); + this.ruleSet = merged; + return { ...this.ruleSet }; + } + + private assertValid(ruleSet: RuleSet): void { + if (ruleSet.mode !== "manual" && ruleSet.mode !== "climate_auto") { + throw new ControlApiError("rules.mode must be one of: manual, climate_auto", 400); + } + + if (!Number.isInteger(ruleSet.minimumHoldSeconds) || ruleSet.minimumHoldSeconds < 5 || ruleSet.minimumHoldSeconds > 7200) { + throw new ControlApiError("rules.minimumHoldSeconds must be an integer between 5 and 7200", 400); + } + + this.assertRange(ruleSet.temperatureTargetC, "rules.temperatureTargetC", 5, 45); + this.assertRange(ruleSet.temperatureBandC, "rules.temperatureBandC", 0.1, 10); + this.assertRange(ruleSet.humidityTargetPercent, "rules.humidityTargetPercent", 10, 90); + this.assertRange(ruleSet.humidityBandPercent, "rules.humidityBandPercent", 1, 30); + } + + private assertRange(value: number, field: string, min: number, max: number): void { + if (!Number.isFinite(value) || value < min || value > max) { + throw new ControlApiError(`${field} must be between ${min} and ${max}`, 400); + } + } +} diff --git a/src/services/TelemetryCache.ts b/src/services/TelemetryCache.ts new file mode 100644 index 0000000..67c439e --- /dev/null +++ b/src/services/TelemetryCache.ts @@ -0,0 +1,13 @@ +import { ClimateSnapshot } from "../domain/ClimateSnapshot"; + +export class TelemetryCache { + private latestSnapshot: ClimateSnapshot | null = null; + + public setLatest(snapshot: ClimateSnapshot): void { + this.latestSnapshot = snapshot; + } + + public getLatest(): ClimateSnapshot | null { + return this.latestSnapshot; + } +} diff --git a/src/ui/template.ts b/src/ui/template.ts new file mode 100644 index 0000000..3687ca7 --- /dev/null +++ b/src/ui/template.ts @@ -0,0 +1,116 @@ +function esc(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderControlUi(defaultMac: string | null): string { + const safeMac = esc(defaultMac ?? ""); + return [ + "", + "", + "", + " ", + " ", + " Typhon v2 Control", + " ", + "", + "", + "

Typhon v2 Control (Scaffold)

", + "
", + `
Default MAC (Vault): ${safeMac || "not set"}
`, + "
", + ` `, + " ", + "
", + "
", + "
", + "", + "

Ports

", + "
", + "", + " ", + "", + "" + ].join("\n"); +} diff --git a/tests/AcInfinityProtocol.test.ts b/tests/AcInfinityProtocol.test.ts new file mode 100644 index 0000000..d994cba --- /dev/null +++ b/tests/AcInfinityProtocol.test.ts @@ -0,0 +1,51 @@ +import { AcInfinityProtocol } from "../src/ble/AcInfinityProtocol"; + +describe("AcInfinityProtocol", () => { + it("builds get-model-data command with port selector for supported device types", () => { + const protocol = new AcInfinityProtocol(); + const packet = protocol.buildGetModelData(11, 2); + + expect(packet[0]).toBe(0xa5); + expect(packet[9]).toBe(1); + expect(Array.from(packet.slice(10, 20))).toEqual([ + 16, 17, 18, 19, 20, 21, 22, 23, 255, 2 + ]); + }); + + it("builds set-level command with explicit port byte", () => { + const protocol = new AcInfinityProtocol(); + const packet = protocol.buildSetLevel(11, 2, 5, 3); + + expect(packet[0]).toBe(0xa5); + expect(packet[9]).toBe(3); + expect(Array.from(packet.slice(10, 18))).toEqual([ + 16, 1, 2, 18, 1, 5, 255, 3 + ]); + }); + + it("parses telemetry notifications", () => { + const protocol = new AcInfinityProtocol(); + + const data = Buffer.alloc(20, 0); + data[0] = 0x1e; + data[1] = 0xff; + data[7] = 0x03; // choose_port raw byte + data[8] = 0x09; + data[9] = 0x6c; // 24.12C + data[10] = 0x11; + data[11] = 0x9e; // 45.10% + data[12] = 0x00; + data[13] = 0xb0; // 1.76kPa + data[17] = 0x62; // fanSpeedGuess=6, workType=2 + + const parsed = protocol.parseTelemetryNotification(data); + + expect(parsed.temperatureCelsius).toBeCloseTo(24.12, 2); + expect(parsed.temperatureFahrenheit).toBeCloseTo(75.416, 3); + expect(parsed.humidityPercent).toBeCloseTo(45.1, 2); + expect(parsed.vpdKpa).toBeCloseTo(1.76, 2); + expect(parsed.choosePort).toBe(3); + expect(parsed.workType).toBe(2); + expect(parsed.fanSpeedGuess).toBe(6); + }); +}); diff --git a/tests/AppConfig.test.ts b/tests/AppConfig.test.ts index ce29dbb..b5f8c06 100644 --- a/tests/AppConfig.test.ts +++ b/tests/AppConfig.test.ts @@ -7,11 +7,19 @@ describe("AppConfig", () => { ACI_PASSWORD: "super-secret" }); + expect(config.mode).toBe("cloud"); expect(config.aciHost).toBe("http://www.acinfinityserver.com"); expect(config.pollIntervalSeconds).toBe(30); expect(config.listenPort).toBe(9108); expect(config.requestTimeoutMs).toBe(10000); expect(config.logLevel).toBe("info"); + expect(config.enableControlApi).toBe(false); + expect(config.controlListenPort).toBe(9110); + expect(config.bleDefaultMac).toBeNull(); + expect(config.bleAllowedMacs).toEqual([]); + expect(config.bleDeviceType).toBe(11); + expect(config.bleScanTimeoutMs).toBe(20000); + expect(config.blePortBase).toBe(1); }); it("throws when required values are missing", () => { @@ -34,4 +42,47 @@ describe("AppConfig", () => { }) ).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600"); }); + + it("supports ble mode without cloud credentials", () => { + const config = AppConfig.fromEnv({ + TYPHON_MODE: "ble", + ENABLE_CONTROL_API: "true", + TY_BLE_DEFAULT_MAC: "58:8c:81:c6:fc:f6", + TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66", + TY_BLE_DEVICE_TYPE: "11", + TY_BLE_SCAN_TIMEOUT_MS: "25000", + TY_BLE_PORT_BASE: "1" + }); + + expect(config.mode).toBe("ble"); + expect(config.aciEmail).toBeNull(); + expect(config.aciPassword).toBeNull(); + expect(config.enableControlApi).toBe(true); + expect(config.bleDefaultMac).toBe("58:8C:81:C6:FC:F6"); + expect(config.bleAllowedMacs).toEqual([ + "11:22:33:44:55:66", + "58:8C:81:C6:FC:F6" + ]); + expect(config.bleDeviceType).toBe(11); + expect(config.bleScanTimeoutMs).toBe(25000); + expect(config.blePortBase).toBe(1); + }); + + it("validates mode and MAC inputs", () => { + expect(() => AppConfig.fromEnv({ + TYPHON_MODE: "unknown", + ACI_EMAIL: "x", + ACI_PASSWORD: "y" + })).toThrow("TYPHON_MODE must be one of: cloud, ble"); + + expect(() => AppConfig.fromEnv({ + TYPHON_MODE: "ble", + TY_BLE_DEFAULT_MAC: "not-a-mac" + })).toThrow("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF"); + + expect(() => AppConfig.fromEnv({ + TYPHON_MODE: "ble", + TY_BLE_PORT_BASE: "2" + })).toThrow("TY_BLE_PORT_BASE must be 0 or 1"); + }); }); diff --git a/tests/BleControlBackend.test.ts b/tests/BleControlBackend.test.ts new file mode 100644 index 0000000..ae453fb --- /dev/null +++ b/tests/BleControlBackend.test.ts @@ -0,0 +1,97 @@ +import { BleControllerClient, BleTelemetryReading } from "../src/ble/BleControllerClient"; +import { Logger } from "../src/observability/Logger"; +import { BleControlBackend } from "../src/services/BleControlBackend"; + +class FakeBleClient implements BleControllerClient { + public lastSet: { mac: string; portByte: number; speedLevel: number } | null = null; + public telemetry: BleTelemetryReading = { + temperatureCelsius: 24.06, + temperatureFahrenheit: 75.31, + humidityPercent: 41.06, + vpdKpa: 1.76, + choosePort: 3, + workType: 2, + fanSpeedGuess: 7, + receivedAtEpochSeconds: 1_700_000_123 + }; + + public async verifyConnection(): Promise { + return; + } + + public async readTelemetry(): Promise { + return this.telemetry; + } + + public async setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise { + this.lastSet = { mac: macAddress, portByte, speedLevel }; + } +} + +describe("BleControlBackend", () => { + it("pairs and enforces allowed MAC list", async () => { + const client = new FakeBleClient(); + const backend = new BleControlBackend({ + defaultMac: null, + allowedMacs: ["58:8C:81:C6:FC:F6"], + requestTimeoutMs: 10000, + scanTimeoutMs: 10000, + deviceType: 11, + portBase: 1, + logger: new Logger("error", "test"), + client + }); + + await expect(backend.pair("11:22:33:44:55:66")).rejects.toThrow("mac address is not in TY_BLE_ALLOWED_MACS"); + + const status = await backend.pair("58:8c:81:c6:fc:f6"); + expect(status.controllerMac).toBe("58:8C:81:C6:FC:F6"); + expect(status.connected).toBe(true); + }); + + it("maps port numbers to device bytes and updates speed cache", async () => { + const client = new FakeBleClient(); + const backend = new BleControlBackend({ + defaultMac: "58:8C:81:C6:FC:F6", + allowedMacs: [], + requestTimeoutMs: 10000, + scanTimeoutMs: 10000, + deviceType: 11, + portBase: 1, + logger: new Logger("error", "test"), + client + }); + + await backend.setPortSpeed(4, 9); + expect(client.lastSet).toEqual({ + mac: "58:8C:81:C6:FC:F6", + portByte: 3, + speedLevel: 9 + }); + + const ports = await backend.getPorts(); + expect(ports.find((p) => p.port === 4)?.currentSpeedLevel).toBe(9); + }); + + it("builds a climate snapshot from BLE telemetry", async () => { + const client = new FakeBleClient(); + const backend = new BleControlBackend({ + defaultMac: "58:8C:81:C6:FC:F6", + allowedMacs: [], + requestTimeoutMs: 10000, + scanTimeoutMs: 10000, + deviceType: 11, + portBase: 1, + logger: new Logger("error", "test"), + client + }); + + const snapshot = await backend.refreshTelemetry(); + const controller = snapshot.controllers[0]; + + expect(controller?.temperatureCelsius).toBeCloseTo(24.06, 2); + expect(controller?.relativeHumidityRatio).toBeCloseTo(0.4106, 4); + expect(controller?.vpdKpa).toBeCloseTo(1.76, 2); + expect(controller?.ports.find((p) => p.port === 4)?.currentSpeedLevel).toBe(7); + }); +}); diff --git a/tests/ControlApiServer.test.ts b/tests/ControlApiServer.test.ts new file mode 100644 index 0000000..a28ee39 --- /dev/null +++ b/tests/ControlApiServer.test.ts @@ -0,0 +1,131 @@ +import http from "node:http"; + +import { ControlApiServer } from "../src/http/ControlApiServer"; +import { ControlPortState, ControllerControlStatus } from "../src/domain/ControlTypes"; +import { Logger } from "../src/observability/Logger"; +import { ControllerControlBackend } from "../src/services/ControllerControlBackend"; +import { RuleEngineService } from "../src/services/RuleEngineService"; + +class FakeBackend implements ControllerControlBackend { + private mac: string | null = null; + private ports: ControlPortState[] = [ + { port: 1, name: "Port 1", fanGroup: "outlet", currentSpeedLevel: 0, online: true, powerState: false }, + { port: 2, name: "Port 2", fanGroup: "inside_inlet", currentSpeedLevel: 0, online: true, powerState: false }, + { port: 3, name: "Port 3", fanGroup: "outside_inlet", currentSpeedLevel: 0, online: true, powerState: false }, + { port: 4, name: "Port 4", fanGroup: "interior", currentSpeedLevel: 0, online: true, powerState: false } + ]; + + public async getStatus(): Promise { + return { + mode: "ble", + backend: "fake", + controllerMac: this.mac, + connected: this.mac !== null, + paired: this.mac !== null, + telemetrySource: "ble", + lastSnapshotEpochSeconds: null, + capabilities: { + pairing: true, + portControl: true, + advancedRules: true + }, + ports: this.ports, + notes: [] + }; + } + + public async pair(macAddress: string): Promise { + this.mac = macAddress; + return this.getStatus(); + } + + public async getPorts(): Promise { + return this.ports; + } + + public async setPortSpeed(port: number, speedLevel: number): Promise { + this.ports = this.ports.map((item) => { + if (item.port !== port) { + return item; + } + return { + ...item, + currentSpeedLevel: speedLevel, + powerState: speedLevel > 0 + }; + }); + } +} + +async function requestJson( + port: number, + method: string, + path: string, + body?: unknown +): Promise<{ statusCode: number; payload: unknown }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port, + method, + path, + headers: body ? { "content-type": "application/json" } : undefined + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + let parsed: unknown = text; + try { + parsed = JSON.parse(text); + } catch { + // non-json response in tests should still be visible + } + resolve({ + statusCode: res.statusCode ?? 0, + payload: parsed + }); + }); + } + ); + + req.on("error", reject); + if (body !== undefined) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +describe("ControlApiServer", () => { + it("supports status, pair, and per-port speed writes", async () => { + const backend = new FakeBackend(); + const rules = new RuleEngineService(); + const logger = new Logger("error", "test"); + const port = 19110; + + const server = new ControlApiServer(port, backend, rules, logger, "58:8C:81:C6:FC:F6"); + await server.start(); + + const statusBefore = await requestJson(port, "GET", "/api/v2/status"); + expect(statusBefore.statusCode).toBe(200); + + const pair = await requestJson(port, "POST", "/api/v2/pair", {}); + expect(pair.statusCode).toBe(200); + + const setSpeed = await requestJson(port, "POST", "/api/v2/ports/4/speed", { speed_level: 8 }); + expect(setSpeed.statusCode).toBe(200); + + const ports = await requestJson(port, "GET", "/api/v2/ports"); + expect(ports.statusCode).toBe(200); + const parsed = ports.payload as { ports?: Array<{ port: number; currentSpeedLevel: number }> }; + const interior = parsed.ports?.find((item) => item.port === 4); + expect(interior?.currentSpeedLevel).toBe(8); + + await server.stop(); + }); +}); diff --git a/tests/RuleEngineService.test.ts b/tests/RuleEngineService.test.ts new file mode 100644 index 0000000..001f650 --- /dev/null +++ b/tests/RuleEngineService.test.ts @@ -0,0 +1,21 @@ +import { RuleEngineService } from "../src/services/RuleEngineService"; + +describe("RuleEngineService", () => { + it("returns defaults and accepts valid updates", () => { + const svc = new RuleEngineService(); + const defaults = svc.getRules(); + expect(defaults.mode).toBe("manual"); + + const updated = svc.updateRules({ mode: "climate_auto", temperatureTargetC: 23.5 }, 1_700_000_123); + expect(updated.mode).toBe("climate_auto"); + expect(updated.temperatureTargetC).toBe(23.5); + expect(updated.updatedAtEpochSeconds).toBe(1_700_000_123); + }); + + it("rejects invalid rules", () => { + const svc = new RuleEngineService(); + expect(() => svc.updateRules({ minimumHoldSeconds: 2 })).toThrow( + "rules.minimumHoldSeconds must be an integer between 5 and 7200" + ); + }); +});