126 lines
6.1 KiB
JavaScript
126 lines
6.1 KiB
JavaScript
import { test } from 'node:test';
|
|||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { readFileSync } from 'node:fs';
|
||
|
|
import ts from 'typescript';
|
||
|
|
|
||
|
|
const source = readFileSync(new URL('../src/ble/client.ts', import.meta.url), 'utf8');
|
||
|
|
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText;
|
||
|
|
const { BLEClient, BLE_SERVICE, BLEDeviceSelectionCancelledError } = await import('data:text/javascript;base64,' + Buffer.from(compiled).toString('base64'));
|
||
|
|
globalThis.window = { isSecureContext: true };
|
||
|
|
|
||
|
|
for (const name of ['NotFoundError', 'AbortError']) test(`localizes ${name} from the device chooser and allows retry`, async () => {
|
||
|
|
Object.defineProperty(globalThis.navigator, 'bluetooth', { configurable: true, value: {
|
||
|
|
async requestDevice() { throw new DOMException('User cancelled the requestDevice() chooser.', name); },
|
||
|
|
} });
|
||
|
|
const client = new BLEClient();
|
||
|
|
await assert.rejects(client.connect(), error => error instanceof BLEDeviceSelectionCancelledError && error.message === '已取消选择设备,可重新搜索。');
|
||
|
|
const { gatt } = fake();
|
||
|
|
assert.equal((await client.connect()).protocol_version, 2);
|
||
|
|
client.disconnect();
|
||
|
|
assert.equal(gatt.connected, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('does not mistake a missing GATT service for cancelling the chooser', async () => {
|
||
|
|
const { gatt } = fake();
|
||
|
|
const missing = new DOMException('Missing GATT service', 'NotFoundError');
|
||
|
|
gatt.getPrimaryService = async () => { throw missing; };
|
||
|
|
await assert.rejects(new BLEClient().connect(), error => error === missing && !(error instanceof BLEDeviceSelectionCancelledError));
|
||
|
|
assert.equal(gatt.connected, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
function fake(mode = 'normal') {
|
||
|
|
const tx = new EventTarget();
|
||
|
|
tx.startNotifications = async () => tx;
|
||
|
|
const writes = [];
|
||
|
|
const commands = [];
|
||
|
|
let input = Buffer.alloc(0);
|
||
|
|
const gatt = {
|
||
|
|
connected: false,
|
||
|
|
async connect() { this.connected = true; return this; },
|
||
|
|
disconnect() { this.connected = false; },
|
||
|
|
async getPrimaryService(id) {
|
||
|
|
assert.equal(id, BLE_SERVICE);
|
||
|
|
return { getCharacteristic: async id => id.includes('2001-') ? rx : tx };
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const rx = { async writeValueWithResponse(bytes) {
|
||
|
|
assert(bytes.length <= 20);
|
||
|
|
writes.push(bytes);
|
||
|
|
input = Buffer.concat([input, bytes]);
|
||
|
|
if (input.length < 2 || input.length < 2 + input.readUInt16LE(0)) return;
|
||
|
|
assert.equal(input.length, 2 + input.readUInt16LE(0));
|
||
|
|
const command = JSON.parse(input.subarray(2).toString('utf8'));
|
||
|
|
commands.push(command);
|
||
|
|
let response = command.cmd === 'get_info'
|
||
|
|
? { device_id: 'KP01-B43A451634A8', model: 'ECHOEAR-S3', protocol_version: mode === 'bad-protocol' ? 1 : 2 }
|
||
|
|
: { status: 'received' };
|
||
|
|
const body = Buffer.from(JSON.stringify(response));
|
||
|
|
const frame = Buffer.concat([Buffer.from([body.length & 255, body.length >> 8]), body]);
|
||
|
|
input = Buffer.alloc(0);
|
||
|
|
if (mode === 'oversize') {
|
||
|
|
tx.value = new DataView(new Uint8Array(21).buffer);
|
||
|
|
tx.dispatchEvent(new Event('characteristicvaluechanged'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
for (let i = 0; i < frame.length; i += 7) {
|
||
|
|
const chunk = Uint8Array.from(frame.subarray(i, i + 7));
|
||
|
|
tx.value = new DataView(chunk.buffer);
|
||
|
|
tx.dispatchEvent(new Event('characteristicvaluechanged'));
|
||
|
|
}
|
||
|
|
} };
|
||
|
|
const device = Object.assign(new EventTarget(), { gatt });
|
||
|
|
Object.defineProperty(globalThis.navigator, 'bluetooth', { configurable: true, value: {
|
||
|
|
async requestDevice({ filters }) {
|
||
|
|
assert.deepEqual(filters, [{ services: [BLE_SERVICE] }]);
|
||
|
|
return device;
|
||
|
|
},
|
||
|
|
} });
|
||
|
|
return { writes, commands, gatt };
|
||
|
|
}
|
||
|
|
|
||
|
|
test('discovers by service and uses the same plaintext v2 frames as the mini program', async () => {
|
||
|
|
const { writes, commands, gatt } = fake();
|
||
|
|
const client = new BLEClient();
|
||
|
|
const info = await client.connect();
|
||
|
|
assert.deepEqual(info, { device_id: 'KP01-B43A451634A8', model: 'ECHOEAR-S3', protocol_version: 2 });
|
||
|
|
assert.deepEqual(commands[0], { cmd: 'get_info' });
|
||
|
|
assert.equal(writes[0][0] | writes[0][1] << 8, Buffer.byteLength(JSON.stringify({ cmd: 'get_info' })));
|
||
|
|
assert.deepEqual(await client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: 'x'.repeat(43) }), { status: 'received' });
|
||
|
|
assert.deepEqual(commands[1], { cmd: 'configure', wifi_ssid: 'HomeWiFi', wifi_password: 'test-only-password', bind_token: 'x'.repeat(43) });
|
||
|
|
client.disconnect();
|
||
|
|
assert.equal(gatt.connected, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const mode of ['oversize', 'bad-protocol']) test(`rejects ${mode} response and disconnects`, async () => {
|
||
|
|
const { gatt } = fake(mode);
|
||
|
|
const client = new BLEClient();
|
||
|
|
await assert.rejects(client.connect());
|
||
|
|
assert.equal(gatt.connected, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects invalid Wi-Fi data before sending it to the device', async () => {
|
||
|
|
const { commands } = fake();
|
||
|
|
const client = new BLEClient();
|
||
|
|
await client.connect();
|
||
|
|
await assert.rejects(client.configure({ ssid: '', password: 'short', token: 'bad' }), /格式不正确/);
|
||
|
|
assert.equal(commands.length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Wi-Fi-only reconfiguration sends an empty token, never a new ownership claim', async () => {
|
||
|
|
const { commands } = fake();
|
||
|
|
const client = new BLEClient();
|
||
|
|
await client.connect();
|
||
|
|
await client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: '' });
|
||
|
|
assert.deepEqual(commands[1], { cmd: 'configure', wifi_ssid: 'HomeWiFi', wifi_password: 'test-only-password', bind_token: '' });
|
||
|
|
await assert.rejects(client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: 'malformed' }));
|
||
|
|
assert.equal(commands.length, 2);
|
||
|
|
client.disconnect();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Factory reset uses a distinct BLE command with no binding token and validates Wi-Fi', async () => {
|
||
|
|
const {commands}=fake();const client=new BLEClient();await client.connect();
|
||
|
|
await client.factoryReset({ssid:'Home',password:'TestOnly123'});
|
||
|
|
assert.deepEqual(commands[1],{cmd:'factory_reset',wifi_ssid:'Home',wifi_password:'TestOnly123',bind_token:''});
|
||
|
|
await assert.rejects(client.factoryReset({ssid:'',password:'short'}));assert.equal(commands.length,2);client.disconnect();
|
||
|
|
});
|