diff --git a/.gitignore b/.gitignore index c378830..0148fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ tmp.js # Compiled build +# Dist +dist + # Logs logs *.log diff --git a/.npmignore b/.npmignore index f5afdbc..5c53f27 100644 --- a/.npmignore +++ b/.npmignore @@ -4,5 +4,4 @@ coverage docs yarn.lock *.test.js -README.md -dist \ No newline at end of file +README.md \ No newline at end of file diff --git a/dist/redux-bluetooth.webapp.js b/dist/redux-bluetooth.webapp.js deleted file mode 100644 index 014b4bb..0000000 --- a/dist/redux-bluetooth.webapp.js +++ /dev/null @@ -1 +0,0 @@ -!function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=12)}([function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});t.BLUETOOTH_CONNECTING="@@bluetooth/CONNECTING",t.BLUETOOTH_CONNECTED="@@bluetooth/CONNECTED",t.BLUETOOTH_ERROR="@@bluetooth/ERROR",t.BLUETOOTH_READ="@@bluetooth/READ",t.BLUETOOTH_SYNC="@@bluetooth/SYNC",t.BLUETOOTH_SEND="@@bluetooth/SEND",t.BLUETOOTH_CONNECT_REQUEST="@@bluetooth/CONNECT_REQUEST",t.BLUETOOTH_SEND_REQUEST="@@bluetooth/SEND_REQUEST",t.BLUETOOTH_SYNC_REQUEST="@@bluetooth/SYNC_REQUEST"},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});t.INIT="INIT",t.DISCONNECTED="DISCONNECTED",t.DISCONNECTING="DISCONNECTING",t.CONNECTING="CONNECTING",t.CONNECTED="CONNECTED",t.ERROR="ERROR"},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var o=n(13),u=r(o),i=n(0),c=function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}(i),f=n(17),a=r(f);t.default=(0,a.default)(u.default,c)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},o=n(2),u=function(e){return e&&e.__esModule?e:{default:e}}(o),i=n(0),c=function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}(i),f=u.default.sendAction,a=c.BLUETOOTH_CONNECT_REQUEST,l=c.BLUETOOTH_SEND_REQUEST,d=[a,l];t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];return function(t){return function(n){return function(o){if("object"!==(void 0===o?"undefined":r(o)))return n(o);var u=o.type;return d.includes(u)&&o.request(t.dispatch),e.includes(u)&&t.dispatch(f(o)),n(o)}}}}},function(e,t,n){"use strict";function r(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}Object.defineProperty(t,"__esModule",{value:!0});var o=n(0),u=r(o),i=n(1),c=r(i),f=n(18),a=function(e){return e&&e.__esModule?e:{default:e}}(f);t.default=function(){var e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];return function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:a.default,n=arguments[1],r=n.type,o=n.payload;switch(r){case u.BLUETOOTH_CONNECTING:return Object.assign({},t,{status:c.CONNECTING});case u.BLUETOOTH_CONNECTED:return Object.assign({},t,{status:c.CONNECTED});case u.BLUETOOTH_SYNC:return e?Object.assign({},t,{store:o}):t;default:return t}}}},function(e,t,n){"use strict";function r(){throw new Error("setTimeout has not been defined")}function o(){throw new Error("clearTimeout has not been defined")}function u(e){if(d===setTimeout)return setTimeout(e,0);if((d===r||!d)&&setTimeout)return d=setTimeout,setTimeout(e,0);try{return d(e,0)}catch(t){try{return d.call(null,e,0)}catch(t){return d.call(this,e,0)}}}function i(e){if(s===clearTimeout)return clearTimeout(e);if((s===o||!s)&&clearTimeout)return s=clearTimeout,clearTimeout(e);try{return s(e)}catch(t){try{return s.call(null,e)}catch(t){return s.call(this,e)}}}function c(){b&&p&&(b=!1,p.length?v=p.concat(v):_=-1,v.length&&f())}function f(){if(!b){var e=u(c);b=!0;for(var t=v.length;t;){for(p=v,v=[];++_1)for(var n=1;n0?"Unexpected "+(i.length>1?"keys":"key")+' "'+i.join('", "')+'" found in '+u+'. Expected to find one of the known reducer keys instead: "'+o.join('", "')+'". Unexpected keys will be ignored.':void 0}function i(e){Object.keys(e).forEach(function(t){var n=e[t];if(void 0===n(void 0,{type:f.ActionTypes.INIT}))throw new Error('Reducer "'+t+"\" returned undefined during initialization. If the state passed to the reducer is undefined, you must explicitly return the initial state. The initial state may not be undefined. If you don't want to set a value for this reducer, you can use null instead of undefined.");if(void 0===n(void 0,{type:"@@redux/PROBE_UNKNOWN_ACTION_"+Math.random().toString(36).substring(7).split("").join(".")}))throw new Error('Reducer "'+t+"\" returned undefined when probed with a random type. Don't try to handle "+f.ActionTypes.INIT+' or other actions in "redux/*" namespace. They are considered private. Instead, you must return the current state for any unknown actions, unless it is undefined, in which case you must return the initial state, regardless of the action type. The initial state may not be undefined, but can be null.')})}function c(t){for(var n=Object.keys(t),r={},c=0;c0&&void 0!==arguments[0]?arguments[0]:{},n=arguments[1];if(d)throw d;if("production"!==e.env.NODE_ENV){var i=u(t,r,n,l);i&&(0,s.default)(i)}for(var c=!1,f={},y=0;y { 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' }); diff --git a/src/webapp/central/central.js b/src/webapp/central/central.js index b35af3f..e16f56a 100644 --- a/src/webapp/central/central.js +++ b/src/webapp/central/central.js @@ -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, }; } diff --git a/src/webapp/central/central.test.js b/src/webapp/central/central.test.js index 7c11f76..28cb9de 100644 --- a/src/webapp/central/central.test.js +++ b/src/webapp/central/central.test.js @@ -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; }); diff --git a/src/webapp/central/index.js b/src/webapp/central/index.js index a61d4c0..13bc404 100644 --- a/src/webapp/central/index.js +++ b/src/webapp/central/index.js @@ -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, diff --git a/src/webapp/central/index.test.js b/src/webapp/central/index.test.js index 8092af3..e561120 100644 --- a/src/webapp/central/index.test.js +++ b/src/webapp/central/index.test.js @@ -11,6 +11,7 @@ global.navigator = { bluetooth: null }; test('central', () => { const result = new Central( + 123, null, true, CENTRAL_CONFIG,