[ISSUE-14] Multiple clients write support (#16)

* [ISSUE-14] Multiple clients write support
This commit is contained in:
jvallelunga 2017-11-01 23:58:12 +02:00 committed by GitHub
parent eb047847f2
commit f5562128fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 218 additions and 59 deletions

3
.gitignore vendored
View File

@ -5,6 +5,9 @@ tmp.js
# Compiled # Compiled
build build
# Dist
dist
# Logs # Logs
logs logs
*.log *.log

View File

@ -4,5 +4,4 @@ coverage
docs docs
yarn.lock yarn.lock
*.test.js *.test.js
README.md README.md
dist

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"name": "redux-bluetooth", "name": "redux-bluetooth",
"version": "0.1.17", "version": "0.1.18",
"description": "Redux middleware to dispatch actions via bluetooth to a peripheral store", "description": "Redux middleware to dispatch actions via bluetooth to a peripheral store",
"main": "build/index.js", "main": "build/index.js",
"scripts": { "scripts": {

View File

@ -2,9 +2,11 @@ export default function Characteristic(uuid, Parent, util, descriptor, { encode,
function ReduxCharacteristic() { function ReduxCharacteristic() {
Parent.call(this, { Parent.call(this, {
uuid, uuid,
properties: ['write', 'notify'], properties: ['writeWithoutResponse', 'read', 'notify'],
descriptors: [descriptor], descriptors: [descriptor],
}); });
this.messages = {};
this.maxValueSize = 20;
} }
util.inherits(ReduxCharacteristic, Parent); util.inherits(ReduxCharacteristic, Parent);
@ -13,18 +15,42 @@ export default function Characteristic(uuid, Parent, util, descriptor, { encode,
function (data, offset, withoutResponse, callback) { function (data, offset, withoutResponse, callback) {
if (offset) { if (offset) {
callback(this.RESULT_ATTR_NOT_LONG); callback(this.RESULT_ATTR_NOT_LONG);
return; return this.RESULT_ATTR_NOT_LONG;
} }
const action = decode(data); const buffer = decode(data);
this.onAction(JSON.parse(action)); 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); 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) { ReduxCharacteristic.prototype.onSubscribe = function (maxValueSize, updateValueCallback) {
this.maxValueSize = maxValueSize; this.maxValueSize = maxValueSize;
this.updateValueCallback = updateValueCallback; this.updateValueCallback = updateValueCallback;
this.configuration = {
limit: maxValueSize,
};
}; };
ReduxCharacteristic.prototype.onUnsubscribe = function () { ReduxCharacteristic.prototype.onUnsubscribe = function () {

View File

@ -16,7 +16,7 @@ let decode = null;
beforeEach(() => { beforeEach(() => {
encode = jest.fn().mockReturnValue('mockEncode'); encode = jest.fn().mockReturnValue('mockEncode');
decode = jest.fn().mockReturnValue('{"mockDecode":"mockDecode"}'); decode = jest.fn().mockReturnValue('123456789:]]]');
}); });
afterEach(() => { afterEach(() => {
@ -25,15 +25,21 @@ afterEach(() => {
}); });
test('new Characteristic', () => { 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.uuid).toBe('mockUUID');
expect(characteristic.properties).toEqual(['write', 'notify']); expect(characteristic.properties).toEqual(['writeWithoutResponse', 'read', 'notify']);
expect(characteristic.descriptors).toEqual(['mockDescriptor']); expect(characteristic.descriptors).toEqual(['mockDescriptor']);
}); });
test('Characteristic.onWriteRequest: RESULT_ATTR_NOT_LONG', () => { 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(); const callback = jest.fn();
characteristic.onWriteRequest(null, true, false, callback); characteristic.onWriteRequest(null, true, false, callback);
@ -41,28 +47,68 @@ test('Characteristic.onWriteRequest: RESULT_ATTR_NOT_LONG', () => {
expect(callback).toBeCalledWith('RESULT_ATTR_NOT_LONG'); expect(callback).toBeCalledWith('RESULT_ATTR_NOT_LONG');
}); });
test('Characteristic.onWriteRequest: RESULT_SUCCESS / Chunk', () => {
test('Characteristic.onWriteRequest: RESULT_SUCCESS', () => {
let callback = null; let callback = null;
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode }); const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', {
encode,
decode,
});
callback = jest.fn(); callback = jest.fn();
const spyOnAction = jest.spyOn(characteristic, 'onAction'); const spyOnAction = jest.spyOn(characteristic, 'onAction');
characteristic.onWriteRequest(null, false, false, callback); characteristic.onWriteRequest(null, false, false, callback);
expect(spyOnAction).toBeCalledWith({ mockDecode: 'mockDecode' }); expect(spyOnAction).not.toBeCalled();
expect(callback).toBeCalledWith('RESULT_SUCCESS'); expect(characteristic.messages[123456789]).toEqual(']]]');
callback = jest.fn();
characteristic.onAction = jest.fn();
characteristic.onWriteRequest(null, false, false, callback);
expect(characteristic.onAction).toBeCalledWith({ mockDecode: 'mockDecode' });
expect(callback).toBeCalledWith('RESULT_SUCCESS'); 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', () => { 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'); characteristic.onSubscribe(null, 'mockUpdateValueCallback');
@ -70,7 +116,10 @@ test('Characteristic.onSubscribe', () => {
}); });
test('Characteristic.onUnsubscribe', () => { 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.onSubscribe(null, 'mockUpdateValueCallback');
characteristic.onUnsubscribe(); characteristic.onUnsubscribe();
@ -79,7 +128,10 @@ test('Characteristic.onUnsubscribe', () => {
}); });
test('Characteristic.updateState', () => { 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' }); characteristic.updateState({ mockState: 'mockState' });

View File

@ -1,4 +1,5 @@
export default function Central( export default function Central(
id,
bluetooth, bluetooth,
{ encode, decode }, { encode, decode },
{ SERVICE_UUID, CHARACTERISTIC_UUID }) { { SERVICE_UUID, CHARACTERISTIC_UUID }) {
@ -6,6 +7,10 @@ export default function Central(
server: null, server: null,
characteristic: null, characteristic: null,
message: '', message: '',
configuration: {
limit: 20,
},
id,
}; };
const connect = name => bluetooth const connect = name => bluetooth
@ -22,29 +27,58 @@ export default function Central(
state.characteristic = characteristic; state.characteristic = characteristic;
}); });
const handler = callback => state.characteristic.startNotifications().then(() => { const listener = callback => (event) => {
const listerner = (event) => { const chunk = decode(event.target.value);
const chunk = decode(event.target.value); const message = `${state.message}${chunk}`;
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;
};
if (message.startsWith('[[[') && message.endsWith(']]]')) { const handler = callback => state.characteristic.startNotifications()
const json = JSON.parse(message.slice(3, message.length - 3)); .then(() => {
callback(json); const eventListener = listener(callback);
state.message = ''; state.characteristic.addEventListener('characteristicvaluechanged', eventListener);
} else { return state.characteristic.readValue();
state.message = message; }).then((data) => {
} const configuration = decode(data);
}; state.configuration = JSON.parse(configuration.slice(3, configuration.length - 3));
state.characteristic.addEventListener('characteristicvaluechanged', listerner); return state.configuration;
return listerner; });
});
const write = (action) => { const write = (action) => {
if (!state.server || !state.server.connected || !state.characteristic) return null; if (!state.server || !state.server.connected || !state.characteristic) return Promise.reject();
const stringify = JSON.stringify(action); const stringify = `[[[${JSON.stringify(action)}]]]`;
const serialized = encode(stringify); 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 { return {
@ -52,5 +86,6 @@ export default function Central(
connect, connect,
handler, handler,
write, write,
listener,
}; };
} }

View File

@ -36,7 +36,7 @@ const bluetooth = {
let central = null; let central = null;
beforeEach(() => { beforeEach(() => {
central = new Central(bluetooth, encoder, CENTRAL_CONFIG); central = new Central(123, bluetooth, encoder, CENTRAL_CONFIG);
}); });
afterEach(() => { afterEach(() => {
@ -60,44 +60,87 @@ test('Central: connect', () => {
test('Central: handler', () => { test('Central: handler', () => {
const callback = jest.fn(); const callback = jest.fn();
expect.assertions(3); expect.assertions(4);
const promise = central.connect('mockName').then(() => central.handler(callback)) const promise = central.connect('mockName').then(() => central.handler(callback))
.then((listerner) => { .then((configuration) => {
expect(configuration).toEqual({ mockDecode: 'mockDecode' });
expect(characteristic.startNotifications).toBeCalled(); expect(characteristic.startNotifications).toBeCalled();
listerner({ target: { value: 'mockEvent' } }); expect(characteristic.readValue).toBeCalled();
expect(callback).toBeCalledWith({ mockDecode: 'mockDecode' });
return true; return true;
}); });
return expect(promise).resolves.toBe(true); return expect(promise).resolves.toBe(true);
}); });
test('Central: handler chunk', () => { test('Central: listener - chunk message', () => {
const callback = jest.fn(); const callback = jest.fn();
expect.assertions(3); expect.assertions(3);
encoder.decode = jest.fn().mockReturnValue('{"mockDecode":"mockDecode"}'); 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)) const promise = central.connect('mockName')
.then((listerner) => { .then(() => {
expect(characteristic.startNotifications).toBeCalled(); const listener = central.listener(callback);
listerner({ target: { value: 'mockEvent' } }); const message = listener({ target: { value: 'mockEvent' } });
expect(callback.mock.calls.length).toEqual(0); expect(callback).not.toBeCalled();
expect(message).toEqual('{"mockDecode":"mockDecode"}');
return true; return true;
}); });
return expect(promise).resolves.toBe(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); 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') const promise = central.connect('mockName')
.then(() => central.write({ type: 'ACTION' })) .then(() => central.write({ type: 'ACTION' }))
.then(() => { .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; return true;
}); });

View File

@ -7,6 +7,7 @@ import Central from './central';
const { navigator, TextDecoder, TextEncoder } = window; const { navigator, TextDecoder, TextEncoder } = window;
export default new Central( export default new Central(
`${Date.now()}`.slice(4, 13), // Client ID
navigator.bluetooth, navigator.bluetooth,
Encoder({ TextEncoder, TextDecoder }), Encoder({ TextEncoder, TextDecoder }),
CENTRAL_CONFIG, CENTRAL_CONFIG,

View File

@ -11,6 +11,7 @@ global.navigator = { bluetooth: null };
test('central', () => { test('central', () => {
const result = new Central( const result = new Central(
123,
null, null,
true, true,
CENTRAL_CONFIG, CENTRAL_CONFIG,