homebridge-centsys 0.1.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/config.schema.json +59 -0
- package/dist/bootstrap.js +44 -0
- package/dist/cli.js +108 -0
- package/dist/client.js +162 -0
- package/dist/coordinator.js +227 -0
- package/dist/diagnostics.js +28 -0
- package/dist/errors.js +28 -0
- package/dist/gateway.js +158 -0
- package/dist/index.js +5 -0
- package/dist/mac-setup.js +23 -0
- package/dist/mqtt-codec.js +129 -0
- package/dist/mqtt-session.js +221 -0
- package/dist/platform.js +167 -0
- package/dist/prepare-auth.js +12 -0
- package/dist/protocol.js +105 -0
- package/dist/session-lock.js +37 -0
- package/dist/settings.js +57 -0
- package/dist/setup.js +186 -0
- package/dist/storage.js +130 -0
- package/dist/ui-server.js +38 -0
- package/docs/INSTALLATION.md +93 -0
- package/docs/RELEASING.md +26 -0
- package/docs/VALIDATION.md +258 -0
- package/homebridge-ui/public/app.js +409 -0
- package/homebridge-ui/public/index.html +257 -0
- package/homebridge-ui/public/style.css +52 -0
- package/homebridge-ui/server.js +1 -0
- package/package.json +67 -0
- package/resources/centsys-ca.crt +9 -0
- package/scripts/check-release.mjs +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dean van Niekerk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# homebridge-centsys
|
|
2
|
+
|
|
3
|
+
A Homebridge plugin in development for CENTURION / CENTSYS SMART+ gates, with a browser login wizard, gate-state monitoring and experimental open/close control.
|
|
4
|
+
|
|
5
|
+
**Status: alpha.5 completed one owner-confirmed physical open-and-close cycle through Apple Home on iHost.** Both commands received success responses on their first attempt, and Home displayed Opening → Open → Closing → Closed. Reliability and real gate-offline testing remain in progress. Automatic account discovery is empty on this installation, so setup supports a manually supplied serial and validated protocol address.
|
|
6
|
+
|
|
7
|
+
The tested controller is **D5 Evo SMART+**, Core and Comms Interface firmware **2.1.0.0**. Experimental command handling is limited to this profile in South Africa and is disabled by default. It requires a fresh MQTT session, serializes commands and never automatically repeats an uncertain activation. One explicit configuration-version mismatch permits a corrected-version retry; this negotiation is tested in simulation and awaits hardware validation.
|
|
8
|
+
|
|
9
|
+
## Homebridge setup
|
|
10
|
+
|
|
11
|
+
Use Homebridge 2.4.x and Node 22 or 24. The first public alpha is being prepared. Once published, install `homebridge-centsys@alpha` in the npm environment used by Homebridge, then restart Homebridge, open the plugin's settings and follow the phone/OTP wizard. Until then, use a development tarball as described in the installation guide. Select a discovered gate to fill its serial and available protocol MAC, or follow the [manual setup guide](docs/INSTALLATION.md#if-discovery-is-empty-or-the-protocol-mac-is-missing). For a D5 Evo SMART+ in South Africa, the experimental fallback takes the serial and Wi-Fi MAC from MyCentsys Pro and fills the protocol MAC only after controller authentication and live status succeed. The helper has passed a live identity/status verification on the tested installation; discovery remains empty there. Check status, save and restart. Session credentials live in Homebridge's persistent storage, so restarting or updating the plugin does not require another login unless the vendor rejects the session.
|
|
12
|
+
|
|
13
|
+
See [installation, storage and control limitations](docs/INSTALLATION.md). No npm release has been published yet. Release metadata targets the `alpha` channel. Publishing follows the [AquaTemp-style GitHub Actions procedure](docs/RELEASING.md), with exact-version approval and npm trusted publishing.
|
|
14
|
+
|
|
15
|
+
## Development and diagnostics
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm ci
|
|
19
|
+
npm run check
|
|
20
|
+
npm pack
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The independent research CLI remains available:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm run prepare:auth
|
|
27
|
+
npm run diagnose -- login
|
|
28
|
+
npm run diagnose -- status
|
|
29
|
+
npm run diagnose -- status-known
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`status-known` requires an owner-only `.local/auth/operator.json` containing your own controller serial. CLI credentials are separate from deployed Homebridge credentials. All diagnostic output omits identifying account/device fields and labels HTTP readings as potentially cached.
|
|
33
|
+
|
|
34
|
+
- [Diagnostic CLI guide](https://github.com/deanvanniekerk/homebridge-centsys/blob/codex/cloud-read-client/docs/DIAGNOSTICS.md)
|
|
35
|
+
- [Implementation and validation evidence](docs/VALIDATION.md)
|
|
36
|
+
- [Feasibility and design](https://github.com/deanvanniekerk/homebridge-centsys/blob/codex/cloud-read-client/docs/FEASIBILITY.md)
|
|
37
|
+
- [Protocol research](https://github.com/deanvanniekerk/homebridge-centsys/blob/codex/cloud-read-client/docs/research/connectivity.md)
|
|
38
|
+
|
|
39
|
+
Independent project, not affiliated with CENTURION, Apple or Homebridge. Adaptation credits and the upstream MIT license are in [third-party notices](THIRD_PARTY_NOTICES.md).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
The HTTPS authentication/discovery/overview/certificate contract, MQTT packet encoding, telemetry layout, status mappings and public broker CA certificate were adapted from [Lex-campbell/centsys_remote](https://github.com/Lex-campbell/centsys_remote) at commit `4d6daab50e4305fe2d3ea2c0d2df6e65737e1dba`. The implementation here is TypeScript and uses stricter decoding and bounded transport. Bootstrap preparation for the CLI and Homebridge settings UI extracts a credential from that same pinned MIT-licensed source into ignored local storage.
|
|
4
|
+
|
|
5
|
+
The upstream copyright and permission notice follows:
|
|
6
|
+
|
|
7
|
+
MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Lex Campbell
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "Centsys",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"customUi": true,
|
|
6
|
+
"headerDisplay": "Sign in and configure your gates in the setup wizard.",
|
|
7
|
+
"schema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"default": "CENTSYS"
|
|
13
|
+
},
|
|
14
|
+
"pollInterval": {
|
|
15
|
+
"title": "Idle polling interval (seconds)",
|
|
16
|
+
"type": "integer",
|
|
17
|
+
"default": 15,
|
|
18
|
+
"minimum": 10,
|
|
19
|
+
"maximum": 300
|
|
20
|
+
},
|
|
21
|
+
"gates": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"maxItems": 10,
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["serialNumber"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"name": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"default": "Gate",
|
|
31
|
+
"minLength": 1,
|
|
32
|
+
"maxLength": 64
|
|
33
|
+
},
|
|
34
|
+
"serialNumber": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"pattern": "^[0-9a-fA-F]{24}$"
|
|
37
|
+
},
|
|
38
|
+
"macAddress": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"pattern": "^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$"
|
|
41
|
+
},
|
|
42
|
+
"enableControl": {
|
|
43
|
+
"type": "boolean",
|
|
44
|
+
"default": false
|
|
45
|
+
},
|
|
46
|
+
"controlProfile": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"enum": ["d5-evo-smart-plus"]
|
|
49
|
+
},
|
|
50
|
+
"triggerModeConfirmed": {
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"default": false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CentsysError } from "./errors.js";
|
|
3
|
+
import { credential } from "./protocol.js";
|
|
4
|
+
// Source-pinned, opt-in setup step. Downloads data only; never executes Python.
|
|
5
|
+
const revision = "4d6daab50e4305fe2d3ea2c0d2df6e65737e1dba";
|
|
6
|
+
const sourceUrl = `https://api.github.com/repos/Lex-campbell/centsys_remote/contents/custom_components/centsys_remote/api/const.py?ref=${revision}`;
|
|
7
|
+
const expectedHash = "a812a8a58d1b1359396ccfe1f605885420465ed5bb820279c4aacc2ee40cc5f1";
|
|
8
|
+
export async function fetchBootstrap() {
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch(sourceUrl, {
|
|
11
|
+
redirect: "error",
|
|
12
|
+
signal: AbortSignal.timeout(10_000),
|
|
13
|
+
headers: {
|
|
14
|
+
Accept: "application/vnd.github.raw+json",
|
|
15
|
+
"User-Agent": "homebridge-centsys/0.0.0",
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok || !response.body) {
|
|
19
|
+
await response.body?.cancel();
|
|
20
|
+
throw new CentsysError("http");
|
|
21
|
+
}
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
for await (const chunk of response.body) {
|
|
25
|
+
bytes += chunk.byteLength;
|
|
26
|
+
if (bytes > 65_536)
|
|
27
|
+
throw new CentsysError("protocol");
|
|
28
|
+
chunks.push(chunk);
|
|
29
|
+
}
|
|
30
|
+
const source = Buffer.concat(chunks);
|
|
31
|
+
if (createHash("sha256").update(source).digest("hex") !== expectedHash)
|
|
32
|
+
throw new CentsysError("protocol");
|
|
33
|
+
const block = /GATEWAY_API_SERVICE_LEVEL_JWT\s*=\s*\(([\s\S]*?)\)/.exec(source.toString("utf8"))?.[1];
|
|
34
|
+
if (!block)
|
|
35
|
+
throw new CentsysError("protocol");
|
|
36
|
+
const token = credential([...block.matchAll(/"([A-Za-z0-9_.-]+)"/g)]
|
|
37
|
+
.map((match) => match[1])
|
|
38
|
+
.join(""));
|
|
39
|
+
return token;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw error instanceof CentsysError ? error : new CentsysError("transport");
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { Writable } from "node:stream";
|
|
3
|
+
import { CentsysReadClient } from "./client.js";
|
|
4
|
+
import { CentsysError } from "./errors.js";
|
|
5
|
+
import { diagnosticReport } from "./diagnostics.js";
|
|
6
|
+
import { credential, normalizeNumber } from "./protocol.js";
|
|
7
|
+
import { forgetSession, readPrivate, readKnownOperator, readSession, saveSession, } from "./storage.js";
|
|
8
|
+
const shutdown = new AbortController();
|
|
9
|
+
const abort = () => shutdown.abort();
|
|
10
|
+
process.once("SIGINT", abort);
|
|
11
|
+
process.once("SIGTERM", abort);
|
|
12
|
+
async function login() {
|
|
13
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
14
|
+
throw new CentsysError("configuration");
|
|
15
|
+
const bootstrapToken = credential((await readPrivate("bootstrap-token")).trim());
|
|
16
|
+
// readline's terminal echo goes to this sink; secrets never reach output/history.
|
|
17
|
+
const sink = new Writable({
|
|
18
|
+
write(_chunk, _encoding, callback) {
|
|
19
|
+
callback();
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const prompt = createInterface({
|
|
23
|
+
input: process.stdin,
|
|
24
|
+
output: sink,
|
|
25
|
+
terminal: true,
|
|
26
|
+
historySize: 0,
|
|
27
|
+
});
|
|
28
|
+
prompt.on("SIGINT", abort);
|
|
29
|
+
async function ask(message) {
|
|
30
|
+
process.stdout.write(message);
|
|
31
|
+
const answer = await prompt.question("", { signal: shutdown.signal });
|
|
32
|
+
process.stdout.write("\n");
|
|
33
|
+
return answer.trim();
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
process.stdout.write("Sign in to your own CENTSYS account. Input is hidden. No gate commands are available.\n");
|
|
37
|
+
const mobileNumber = normalizeNumber(await ask("Registered phone number, including +country code: "));
|
|
38
|
+
const regionInput = (await ask("Region [za/au, default za]: ")) || "za";
|
|
39
|
+
if (regionInput !== "za" && regionInput !== "au")
|
|
40
|
+
throw new CentsysError("configuration");
|
|
41
|
+
const channel = (await ask("Send one OTP via [whatsapp/sms, default whatsapp]: ")) ||
|
|
42
|
+
"whatsapp";
|
|
43
|
+
if (channel !== "whatsapp" && channel !== "sms")
|
|
44
|
+
throw new CentsysError("configuration");
|
|
45
|
+
const client = new CentsysReadClient({
|
|
46
|
+
mobileNumber,
|
|
47
|
+
region: regionInput,
|
|
48
|
+
bootstrapToken,
|
|
49
|
+
});
|
|
50
|
+
await client.sendOtp(channel, shutdown.signal);
|
|
51
|
+
process.stdout.write("The service accepted the OTP request. Delivery is not guaranteed.\n");
|
|
52
|
+
const token = await client.validateOtp(await ask("One-time code: "), shutdown.signal);
|
|
53
|
+
await saveSession({ mobileNumber, region: regionInput, token });
|
|
54
|
+
process.stdout.write("Signed in. Session saved privately under .local/auth/. Run: npm run diagnose -- status\n");
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
prompt.close();
|
|
58
|
+
sink.destroy();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function main() {
|
|
62
|
+
const args = process.argv.slice(2);
|
|
63
|
+
if (args.length === 0 || (args.length === 1 && args[0] === "--help")) {
|
|
64
|
+
process.stdout.write("Read-only CENTSYS diagnostics\n\n npm run prepare:auth Fetch the pinned bootstrap credential locally\n npm run diagnose -- login Interactive phone/OTP sign-in (hidden input)\n npm run diagnose -- status Discover gates and print a sanitized cloud overview\n npm run diagnose -- status-known Read the operator in private operator.json\n npm run diagnose -- logout Remove the local session (does not revoke it remotely)\n");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (args.length !== 1)
|
|
68
|
+
throw new CentsysError("configuration");
|
|
69
|
+
if (args[0] === "login")
|
|
70
|
+
return login();
|
|
71
|
+
if (args[0] === "logout") {
|
|
72
|
+
await forgetSession();
|
|
73
|
+
process.stdout.write("Local session removed.\n");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (args[0] !== "status" && args[0] !== "status-known")
|
|
77
|
+
throw new CentsysError("configuration");
|
|
78
|
+
const manual = args[0] === "status-known";
|
|
79
|
+
const session = await readSession();
|
|
80
|
+
const client = new CentsysReadClient({
|
|
81
|
+
mobileNumber: session.mobileNumber,
|
|
82
|
+
region: session.region,
|
|
83
|
+
sessionToken: session.token,
|
|
84
|
+
});
|
|
85
|
+
const devices = manual
|
|
86
|
+
? [await readKnownOperator()]
|
|
87
|
+
: await client.discover(shutdown.signal);
|
|
88
|
+
const overviews = await client.overview(devices, shutdown.signal);
|
|
89
|
+
process.stdout.write(`${JSON.stringify({ ...diagnosticReport(devices, overviews, new Date()), identitySource: manual ? "owner-configured" : "cloud-discovery" }, null, 2)}\n`);
|
|
90
|
+
if (devices.length === 0)
|
|
91
|
+
process.stderr.write("No linked operators were returned. This does not establish that the account has no locally paired devices.\n");
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await main();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const safe = shutdown.signal.aborted
|
|
98
|
+
? new CentsysError("cancelled")
|
|
99
|
+
: error instanceof CentsysError
|
|
100
|
+
? error
|
|
101
|
+
: new CentsysError("local-storage");
|
|
102
|
+
process.stderr.write(`${safe.code}: ${safe.message}\n`);
|
|
103
|
+
process.exitCode = shutdown.signal.aborted ? 130 : 1;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
process.removeListener("SIGINT", abort);
|
|
107
|
+
process.removeListener("SIGTERM", abort);
|
|
108
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { CentsysError } from "./errors.js";
|
|
2
|
+
import { credential, decodeDevices, decodeOverviews, normalizeNumber, record, } from "./protocol.js";
|
|
3
|
+
const origins = {
|
|
4
|
+
za: "https://centsys.southafricanorth.cloudapp.azure.com:4445",
|
|
5
|
+
au: "https://centsys.australiaeast.cloudapp.azure.com:4445",
|
|
6
|
+
};
|
|
7
|
+
export class CentsysReadClient {
|
|
8
|
+
#mobileNumber;
|
|
9
|
+
#origin;
|
|
10
|
+
#fetch;
|
|
11
|
+
#timeoutMs;
|
|
12
|
+
#bootstrapToken;
|
|
13
|
+
#sessionToken;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.#mobileNumber = normalizeNumber(options.mobileNumber);
|
|
16
|
+
if (options.region !== "za" && options.region !== "au")
|
|
17
|
+
throw new CentsysError("configuration");
|
|
18
|
+
this.#origin = origins[options.region];
|
|
19
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
20
|
+
this.#timeoutMs = options.timeoutMs ?? 10_000;
|
|
21
|
+
if (!Number.isInteger(this.#timeoutMs) ||
|
|
22
|
+
this.#timeoutMs < 1 ||
|
|
23
|
+
this.#timeoutMs > 30_000) {
|
|
24
|
+
throw new CentsysError("configuration");
|
|
25
|
+
}
|
|
26
|
+
this.#bootstrapToken =
|
|
27
|
+
options.bootstrapToken === undefined
|
|
28
|
+
? undefined
|
|
29
|
+
: credential(options.bootstrapToken);
|
|
30
|
+
this.#sessionToken =
|
|
31
|
+
options.sessionToken === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: credential(options.sessionToken);
|
|
34
|
+
}
|
|
35
|
+
async #request(operation, body, authenticated, signal) {
|
|
36
|
+
const bearer = authenticated
|
|
37
|
+
? this.#sessionToken
|
|
38
|
+
: (this.#sessionToken ?? this.#bootstrapToken);
|
|
39
|
+
if (!bearer)
|
|
40
|
+
throw new CentsysError("authentication");
|
|
41
|
+
const url = new URL(`/${operation}`, this.#origin);
|
|
42
|
+
if (operation === "GetDevicesByRemoteUserNumber")
|
|
43
|
+
url.searchParams.set("remoteUserNumber", this.#mobileNumber);
|
|
44
|
+
const deadline = AbortSignal.timeout(this.#timeoutMs);
|
|
45
|
+
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
46
|
+
try {
|
|
47
|
+
combined.throwIfAborted();
|
|
48
|
+
const response = await this.#fetch(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
redirect: "error",
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${bearer}`,
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
Accept: "application/json",
|
|
55
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
56
|
+
"User-Agent": "homebridge-centsys/0.0.0",
|
|
57
|
+
},
|
|
58
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
59
|
+
signal: combined,
|
|
60
|
+
});
|
|
61
|
+
// Do not retain or print error responses; they may echo account data.
|
|
62
|
+
if (response.status !== 200) {
|
|
63
|
+
await response.body?.cancel();
|
|
64
|
+
if (response.status === 401 || response.status === 403)
|
|
65
|
+
throw new CentsysError("authentication");
|
|
66
|
+
if (response.status === 429)
|
|
67
|
+
throw new CentsysError("rate-limited");
|
|
68
|
+
throw new CentsysError("http");
|
|
69
|
+
}
|
|
70
|
+
const reader = response.body?.getReader();
|
|
71
|
+
if (!reader)
|
|
72
|
+
throw new CentsysError("protocol");
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let bytes = 0;
|
|
75
|
+
try {
|
|
76
|
+
while (true) {
|
|
77
|
+
const chunk = await reader.read();
|
|
78
|
+
if (chunk.done)
|
|
79
|
+
break;
|
|
80
|
+
bytes += chunk.value.byteLength;
|
|
81
|
+
if (bytes > 1_048_576)
|
|
82
|
+
throw new CentsysError("protocol");
|
|
83
|
+
chunks.push(chunk.value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await reader.cancel().catch(() => { });
|
|
88
|
+
reader.releaseLock();
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new CentsysError("protocol");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (signal?.aborted)
|
|
99
|
+
throw new CentsysError("cancelled");
|
|
100
|
+
if (deadline.aborted)
|
|
101
|
+
throw new CentsysError("timeout");
|
|
102
|
+
if (error instanceof CentsysError)
|
|
103
|
+
throw error;
|
|
104
|
+
throw new CentsysError("transport");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async sendOtp(channel, signal) {
|
|
108
|
+
if (channel !== "whatsapp" && channel !== "sms")
|
|
109
|
+
throw new CentsysError("configuration");
|
|
110
|
+
const result = await this.#request("SendOtp", {
|
|
111
|
+
MobileNumber: this.#mobileNumber,
|
|
112
|
+
OtpPlatform: channel === "whatsapp" ? 1 : 2,
|
|
113
|
+
ThreeLetterIsoLanguageName: "eng",
|
|
114
|
+
}, false, signal);
|
|
115
|
+
if (result !== true)
|
|
116
|
+
throw new CentsysError("otp-not-sent");
|
|
117
|
+
}
|
|
118
|
+
async validateOtp(otp, signal) {
|
|
119
|
+
if (!/^\d{4,10}$/.test(otp))
|
|
120
|
+
throw new CentsysError("configuration");
|
|
121
|
+
const result = record(await this.#request("ValidateOtp", { MobileNumber: this.#mobileNumber, Otp: otp }, false, signal));
|
|
122
|
+
if (result.response === "")
|
|
123
|
+
throw new CentsysError("otp-rejected");
|
|
124
|
+
if (typeof result.response !== "string")
|
|
125
|
+
throw new CentsysError("protocol");
|
|
126
|
+
let token;
|
|
127
|
+
try {
|
|
128
|
+
token = credential(result.response);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new CentsysError("protocol");
|
|
132
|
+
}
|
|
133
|
+
this.#sessionToken = token;
|
|
134
|
+
return token;
|
|
135
|
+
}
|
|
136
|
+
async certificate(signal) {
|
|
137
|
+
const data = record(await this.#request("GetCertificate", {}, true, signal));
|
|
138
|
+
const fields = Object.fromEntries(Object.entries(data).map(([k, v]) => [k.toLowerCase(), v]));
|
|
139
|
+
const pfx = fields.certificatepfxbase64 ?? fields.pfxbase64;
|
|
140
|
+
const password = fields.certificatepassword ?? fields.password ?? "";
|
|
141
|
+
if (typeof pfx !== "string" ||
|
|
142
|
+
pfx.length < 4 ||
|
|
143
|
+
pfx.length > 131072 ||
|
|
144
|
+
!/^[A-Za-z0-9+/]+={0,2}$/.test(pfx) ||
|
|
145
|
+
pfx.length % 4 !== 0 ||
|
|
146
|
+
typeof password !== "string" ||
|
|
147
|
+
password.length > 4096)
|
|
148
|
+
throw new CentsysError("protocol");
|
|
149
|
+
return { pfx: Buffer.from(pfx, "base64"), password };
|
|
150
|
+
}
|
|
151
|
+
async discover(signal) {
|
|
152
|
+
return decodeDevices(await this.#request("GetDevicesByRemoteUserNumber", undefined, true, signal));
|
|
153
|
+
}
|
|
154
|
+
async overview(devices, signal) {
|
|
155
|
+
if (devices.length === 0)
|
|
156
|
+
return [];
|
|
157
|
+
// Revalidate identities even when a library consumer constructs the list manually.
|
|
158
|
+
const validated = decodeDevices(devices.map((device) => ({ serialNumber: device.serialNumber })));
|
|
159
|
+
const serials = validated.map((device) => device.serialNumber);
|
|
160
|
+
return decodeOverviews(await this.#request("GetOperatorOverview", { OperatorSerialNumbers: serials }, true, signal), new Set(serials));
|
|
161
|
+
}
|
|
162
|
+
}
|