[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
build
# Dist
dist
# Logs
logs
*.log

View File

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

File diff suppressed because one or more lines are too long

View File

@ -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": {

View File

@ -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 () {

View File

@ -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' });

View File

@ -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 chunk = decode(event.target.value);
const message = `${state.message}${chunk}`;
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;
};
if (message.startsWith('[[[') && message.endsWith(']]]')) {
const json = JSON.parse(message.slice(3, message.length - 3));
callback(json);
state.message = '';
} else {
state.message = 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,
};
}

View File

@ -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;
});

View File

@ -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,

View File

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