[ISSUE-14] Multiple clients write support (#16)
* [ISSUE-14] Multiple clients write support
This commit is contained in:
parent
eb047847f2
commit
f5562128fb
|
@ -5,6 +5,9 @@ tmp.js
|
|||
# Compiled
|
||||
build
|
||||
|
||||
# Dist
|
||||
dist
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
|
|
@ -5,4 +5,3 @@ docs
|
|||
yarn.lock
|
||||
*.test.js
|
||||
README.md
|
||||
dist
|
File diff suppressed because one or more lines are too long
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "redux-bluetooth",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"description": "Redux middleware to dispatch actions via bluetooth to a peripheral store",
|
||||
"main": "build/index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -2,9 +2,11 @@ export default function Characteristic(uuid, Parent, util, descriptor, { encode,
|
|||
function ReduxCharacteristic() {
|
||||
Parent.call(this, {
|
||||
uuid,
|
||||
properties: ['write', 'notify'],
|
||||
properties: ['writeWithoutResponse', 'read', 'notify'],
|
||||
descriptors: [descriptor],
|
||||
});
|
||||
this.messages = {};
|
||||
this.maxValueSize = 20;
|
||||
}
|
||||
|
||||
util.inherits(ReduxCharacteristic, Parent);
|
||||
|
@ -13,18 +15,42 @@ export default function Characteristic(uuid, Parent, util, descriptor, { encode,
|
|||
function (data, offset, withoutResponse, callback) {
|
||||
if (offset) {
|
||||
callback(this.RESULT_ATTR_NOT_LONG);
|
||||
return;
|
||||
return this.RESULT_ATTR_NOT_LONG;
|
||||
}
|
||||
|
||||
const action = decode(data);
|
||||
this.onAction(JSON.parse(action));
|
||||
const buffer = decode(data);
|
||||
const id = buffer.slice(0, 9);
|
||||
const chunk = buffer.slice(10, this.maxValueSize);
|
||||
const message = ((this.messages[id] || '') + chunk);
|
||||
if (message.startsWith('[[[') && message.endsWith(']]]')) {
|
||||
const action = JSON.parse(message.slice(3, message.length - 3));
|
||||
this.onAction(action);
|
||||
this.messages[id] = '';
|
||||
} else {
|
||||
this.messages[id] = message;
|
||||
}
|
||||
|
||||
callback(this.RESULT_SUCCESS);
|
||||
return this.RESULT_SUCCESS;
|
||||
};
|
||||
|
||||
ReduxCharacteristic.prototype.onReadRequest = function (offset, callback) {
|
||||
if (offset) {
|
||||
callback(this.RESULT_ATTR_NOT_LONG, null);
|
||||
return;
|
||||
}
|
||||
const configuration = encode(
|
||||
`|||${JSON.stringify(this.configuration)}|||`,
|
||||
);
|
||||
callback(this.RESULT_SUCCESS, configuration);
|
||||
};
|
||||
|
||||
ReduxCharacteristic.prototype.onSubscribe = function (maxValueSize, updateValueCallback) {
|
||||
this.maxValueSize = maxValueSize;
|
||||
this.updateValueCallback = updateValueCallback;
|
||||
this.configuration = {
|
||||
limit: maxValueSize,
|
||||
};
|
||||
};
|
||||
|
||||
ReduxCharacteristic.prototype.onUnsubscribe = function () {
|
||||
|
|
|
@ -16,7 +16,7 @@ let decode = null;
|
|||
|
||||
beforeEach(() => {
|
||||
encode = jest.fn().mockReturnValue('mockEncode');
|
||||
decode = jest.fn().mockReturnValue('{"mockDecode":"mockDecode"}');
|
||||
decode = jest.fn().mockReturnValue('123456789:]]]');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -25,15 +25,21 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
test('new Characteristic', () => {
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
expect(characteristic.uuid).toBe('mockUUID');
|
||||
expect(characteristic.properties).toEqual(['write', 'notify']);
|
||||
expect(characteristic.properties).toEqual(['writeWithoutResponse', 'read', 'notify']);
|
||||
expect(characteristic.descriptors).toEqual(['mockDescriptor']);
|
||||
});
|
||||
|
||||
test('Characteristic.onWriteRequest: RESULT_ATTR_NOT_LONG', () => {
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
const callback = jest.fn();
|
||||
characteristic.onWriteRequest(null, true, false, callback);
|
||||
|
@ -41,28 +47,68 @@ test('Characteristic.onWriteRequest: RESULT_ATTR_NOT_LONG', () => {
|
|||
expect(callback).toBeCalledWith('RESULT_ATTR_NOT_LONG');
|
||||
});
|
||||
|
||||
|
||||
test('Characteristic.onWriteRequest: RESULT_SUCCESS', () => {
|
||||
test('Characteristic.onWriteRequest: RESULT_SUCCESS / Chunk', () => {
|
||||
let callback = null;
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
callback = jest.fn();
|
||||
const spyOnAction = jest.spyOn(characteristic, 'onAction');
|
||||
characteristic.onWriteRequest(null, false, false, callback);
|
||||
|
||||
expect(spyOnAction).toBeCalledWith({ mockDecode: 'mockDecode' });
|
||||
expect(callback).toBeCalledWith('RESULT_SUCCESS');
|
||||
|
||||
callback = jest.fn();
|
||||
characteristic.onAction = jest.fn();
|
||||
characteristic.onWriteRequest(null, false, false, callback);
|
||||
|
||||
expect(characteristic.onAction).toBeCalledWith({ mockDecode: 'mockDecode' });
|
||||
expect(spyOnAction).not.toBeCalled();
|
||||
expect(characteristic.messages[123456789]).toEqual(']]]');
|
||||
expect(callback).toBeCalledWith('RESULT_SUCCESS');
|
||||
});
|
||||
|
||||
test('Characteristic.onWriteRequest: RESULT_SUCCESS / Message', () => {
|
||||
let callback = null;
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
callback = jest.fn();
|
||||
const spyOnAction = jest.spyOn(characteristic, 'onAction');
|
||||
characteristic.messages[123456789] = '[[[{"mockDecode":"mockDecode"}';
|
||||
characteristic.onWriteRequest(null, false, false, callback);
|
||||
|
||||
expect(spyOnAction).toBeCalledWith({ mockDecode: 'mockDecode' });
|
||||
expect(characteristic.messages[123456789]).toEqual('');
|
||||
expect(callback).toBeCalledWith('RESULT_SUCCESS');
|
||||
});
|
||||
|
||||
test('Characteristic.onReadRequest: RESULT_ATTR_NOT_LONG', () => {
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
const callback = jest.fn();
|
||||
characteristic.onReadRequest(true, callback);
|
||||
|
||||
expect(callback).toBeCalledWith('RESULT_ATTR_NOT_LONG', null);
|
||||
});
|
||||
|
||||
test('Characteristic.onReadRequest: RESULT_SUCCESS', () => {
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
const callback = jest.fn();
|
||||
characteristic.onReadRequest(false, callback);
|
||||
|
||||
expect(callback).toBeCalledWith('RESULT_SUCCESS', 'mockEncode');
|
||||
});
|
||||
|
||||
test('Characteristic.onSubscribe', () => {
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
characteristic.onSubscribe(null, 'mockUpdateValueCallback');
|
||||
|
||||
|
@ -70,7 +116,10 @@ test('Characteristic.onSubscribe', () => {
|
|||
});
|
||||
|
||||
test('Characteristic.onUnsubscribe', () => {
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
characteristic.onSubscribe(null, 'mockUpdateValueCallback');
|
||||
characteristic.onUnsubscribe();
|
||||
|
@ -79,7 +128,10 @@ test('Characteristic.onUnsubscribe', () => {
|
|||
});
|
||||
|
||||
test('Characteristic.updateState', () => {
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
|
||||
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
|
||||
encode,
|
||||
decode,
|
||||
});
|
||||
|
||||
characteristic.updateState({ mockState: 'mockState' });
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export default function Central(
|
||||
id,
|
||||
bluetooth,
|
||||
{ encode, decode },
|
||||
{ SERVICE_UUID, CHARACTERISTIC_UUID }) {
|
||||
|
@ -6,6 +7,10 @@ export default function Central(
|
|||
server: null,
|
||||
characteristic: null,
|
||||
message: '',
|
||||
configuration: {
|
||||
limit: 20,
|
||||
},
|
||||
id,
|
||||
};
|
||||
|
||||
const connect = name => bluetooth
|
||||
|
@ -22,29 +27,58 @@ export default function Central(
|
|||
state.characteristic = characteristic;
|
||||
});
|
||||
|
||||
const handler = callback => state.characteristic.startNotifications().then(() => {
|
||||
const listerner = (event) => {
|
||||
const listener = callback => (event) => {
|
||||
const chunk = decode(event.target.value);
|
||||
const message = `${state.message}${chunk}`;
|
||||
|
||||
if (message.startsWith('[[[') && message.endsWith(']]]')) {
|
||||
const json = JSON.parse(message.slice(3, message.length - 3));
|
||||
callback(json);
|
||||
state.message = '';
|
||||
} else if (message.startsWith('|||') && message.endsWith('|||')) {
|
||||
state.message = '';
|
||||
} else {
|
||||
state.message = message;
|
||||
}
|
||||
return state.message;
|
||||
};
|
||||
state.characteristic.addEventListener('characteristicvaluechanged', listerner);
|
||||
return listerner;
|
||||
|
||||
const handler = callback => state.characteristic.startNotifications()
|
||||
.then(() => {
|
||||
const eventListener = listener(callback);
|
||||
state.characteristic.addEventListener('characteristicvaluechanged', eventListener);
|
||||
return state.characteristic.readValue();
|
||||
}).then((data) => {
|
||||
const configuration = decode(data);
|
||||
state.configuration = JSON.parse(configuration.slice(3, configuration.length - 3));
|
||||
return state.configuration;
|
||||
});
|
||||
|
||||
const write = (action) => {
|
||||
if (!state.server || !state.server.connected || !state.characteristic) return null;
|
||||
const stringify = JSON.stringify(action);
|
||||
const serialized = encode(stringify);
|
||||
if (!state.server || !state.server.connected || !state.characteristic) return Promise.reject();
|
||||
const stringify = `[[[${JSON.stringify(action)}]]]`;
|
||||
const message = encode(stringify);
|
||||
const key = encode(`${state.id}:`);
|
||||
const dataSize = state.configuration.limit - key.length;
|
||||
const writes = [];
|
||||
|
||||
return state.characteristic.writeValue(serialized);
|
||||
|
||||
let i = 0;
|
||||
do {
|
||||
const next = i + dataSize;
|
||||
const end = Math.min(next, message.length);
|
||||
const data = message.slice(i, end);
|
||||
const buffer = new Uint8Array(key.length + data.length);
|
||||
buffer.set(key, 0);
|
||||
buffer.set(data, key.length);
|
||||
writes.push(buffer);
|
||||
|
||||
i = next;
|
||||
} while (i < message.length);
|
||||
|
||||
// Serialize Promises
|
||||
return writes.reduce((promise, chunk) =>
|
||||
promise.then(() => state.characteristic.writeValue(chunk)),
|
||||
Promise.resolve());
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -52,5 +86,6 @@ export default function Central(
|
|||
connect,
|
||||
handler,
|
||||
write,
|
||||
listener,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ const bluetooth = {
|
|||
let central = null;
|
||||
|
||||
beforeEach(() => {
|
||||
central = new Central(bluetooth, encoder, CENTRAL_CONFIG);
|
||||
central = new Central(123, bluetooth, encoder, CENTRAL_CONFIG);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -60,44 +60,87 @@ test('Central: connect', () => {
|
|||
|
||||
test('Central: handler', () => {
|
||||
const callback = jest.fn();
|
||||
expect.assertions(3);
|
||||
expect.assertions(4);
|
||||
|
||||
const promise = central.connect('mockName').then(() => central.handler(callback))
|
||||
.then((listerner) => {
|
||||
.then((configuration) => {
|
||||
expect(configuration).toEqual({ mockDecode: 'mockDecode' });
|
||||
expect(characteristic.startNotifications).toBeCalled();
|
||||
listerner({ target: { value: 'mockEvent' } });
|
||||
expect(callback).toBeCalledWith({ mockDecode: 'mockDecode' });
|
||||
expect(characteristic.readValue).toBeCalled();
|
||||
return true;
|
||||
});
|
||||
|
||||
return expect(promise).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('Central: handler chunk', () => {
|
||||
test('Central: listener - chunk message', () => {
|
||||
const callback = jest.fn();
|
||||
expect.assertions(3);
|
||||
|
||||
encoder.decode = jest.fn().mockReturnValue('{"mockDecode":"mockDecode"}');
|
||||
central = new Central(bluetooth, encoder, CENTRAL_CONFIG);
|
||||
central = new Central(123, bluetooth, encoder, CENTRAL_CONFIG);
|
||||
|
||||
const promise = central.connect('mockName').then(() => central.handler(callback))
|
||||
.then((listerner) => {
|
||||
expect(characteristic.startNotifications).toBeCalled();
|
||||
listerner({ target: { value: 'mockEvent' } });
|
||||
expect(callback.mock.calls.length).toEqual(0);
|
||||
const promise = central.connect('mockName')
|
||||
.then(() => {
|
||||
const listener = central.listener(callback);
|
||||
const message = listener({ target: { value: 'mockEvent' } });
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(message).toEqual('{"mockDecode":"mockDecode"}');
|
||||
return true;
|
||||
});
|
||||
|
||||
return expect(promise).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('Central: write', () => {
|
||||
test('Central: listener - complete message', () => {
|
||||
const callback = jest.fn();
|
||||
expect.assertions(3);
|
||||
|
||||
encoder.decode = jest.fn().mockReturnValue('[[[{"mockDecode":"mockDecode"}]]]');
|
||||
central = new Central(123, bluetooth, encoder, CENTRAL_CONFIG);
|
||||
|
||||
const promise = central.connect('mockName')
|
||||
.then(() => {
|
||||
const listener = central.listener(callback);
|
||||
const message = listener({ target: { value: 'mockEvent' } });
|
||||
expect(callback).toBeCalledWith({ mockDecode: 'mockDecode' });
|
||||
expect(message).toEqual('');
|
||||
return true;
|
||||
});
|
||||
|
||||
return expect(promise).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('Central: listener - internal message', () => {
|
||||
const callback = jest.fn();
|
||||
expect.assertions(3);
|
||||
|
||||
encoder.decode = jest.fn().mockReturnValue('|||{"mockDecode":"mockDecode"}|||');
|
||||
central = new Central(123, bluetooth, encoder, CENTRAL_CONFIG);
|
||||
|
||||
const promise = central.connect('mockName')
|
||||
.then(() => {
|
||||
const listener = central.listener(callback);
|
||||
const message = listener({ target: { value: 'mockEvent' } });
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(message).toEqual('');
|
||||
return true;
|
||||
});
|
||||
|
||||
return expect(promise).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('Central: write - empty message', () => {
|
||||
expect.assertions(2);
|
||||
|
||||
encoder.encode = jest.fn().mockReturnValue([1, 2, 3, 4, 5, 6, 7, 8, 9, 0]);
|
||||
central = new Central(123, bluetooth, encoder, CENTRAL_CONFIG);
|
||||
|
||||
const promise = central.connect('mockName')
|
||||
.then(() => central.write({ type: 'ACTION' }))
|
||||
.then(() => {
|
||||
expect(characteristic.writeValue).toBeCalledWith('mockEncode');
|
||||
expect(characteristic.writeValue)
|
||||
.toBeCalledWith(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0]));
|
||||
return true;
|
||||
});
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import Central from './central';
|
|||
const { navigator, TextDecoder, TextEncoder } = window;
|
||||
|
||||
export default new Central(
|
||||
`${Date.now()}`.slice(4, 13), // Client ID
|
||||
navigator.bluetooth,
|
||||
Encoder({ TextEncoder, TextDecoder }),
|
||||
CENTRAL_CONFIG,
|
||||
|
|
|
@ -11,6 +11,7 @@ global.navigator = { bluetooth: null };
|
|||
|
||||
test('central', () => {
|
||||
const result = new Central(
|
||||
123,
|
||||
null,
|
||||
true,
|
||||
CENTRAL_CONFIG,
|
||||
|
|
Loading…
Reference in New Issue