This commit is contained in:
Jeronimo Vallelunga 2017-07-10 23:01:36 -03:00
parent 418ec8a2c7
commit 7c047f8231
42 changed files with 2997 additions and 0 deletions

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
# Compiled
build
# Logs
logs
*.log

2
.npmignore Normal file
View File

@ -0,0 +1,2 @@
src
example

View File

@ -0,0 +1,23 @@
# Redux Bluetooth
```shell
$ npm install redux-bluetooth
```
```javascript
import { dummy } from 'redux-bluetooth';
console.log(dummy());
// hello world!
```
# TODO
- redux-observable
- CI: CircleCI | Travis
- Coverage: Coverall | https://codecov.io/
- Eslint
- bundlesize
- Badges: ci, coverage, npm
- https://greenkeeper.io/

BIN
docs/logo.afphoto Normal file

Binary file not shown.

BIN
docs/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

View File

@ -0,0 +1,45 @@
{
"name": "redux-bluetooth-counter-peripheral",
"version": "0.1.0",
"description": "Redux Bluetooth example: Counter peripheral",
"main": "build/index.js",
"scripts": {
"prestart": "npm run build",
"start": "node build/index.js",
"dev": "watch 'npm run build' src",
"build": "babel src -d build",
"test": "jest",
"test:watch": "npm test -- --watch"
},
"babel": {
"presets": [
"latest"
]
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/jvallelunga/redux-bluetooth.git"
},
"keywords": [
"redux-bluetooth",
"example",
"counter",
"peripheral"
],
"author": "Jeronimo Vallelunga",
"license": "ISC",
"bugs": {
"url": "https://github.com/jvallelunga/redux-bluetooth/issues"
},
"homepage": "https://github.com/jvallelunga/redux-bluetooth#readme",
"devDependencies": {
"babel-cli": "^6.24.1",
"babel-preset-latest": "^6.24.1",
"jest": "^20.0.4",
"watch": "^1.0.2"
},
"dependencies": {
"redux": "^3.7.1",
"redux-bluetooth": "file:../../"
}
}

View File

@ -0,0 +1,8 @@
import { createStore } from 'redux';
import { startPeripheral } from 'redux-bluetooth/build/peripheral';
import reducer from './reducer';
let store = createStore(reducer);
startPeripheral('Counter', store);

View File

@ -0,0 +1,13 @@
export default function counter(state = 0, { type }) {
console.log('Counter: ---------------------------');
console.log(type)
console.log(state)
switch (type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}

21
example/webapp/.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

2138
example/webapp/README.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
{
"name": "redux-bluetooth-counter-webapp",
"version": "0.1.0",
"description": "Redux Bluetooth example: Counter webapp",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject",
"predeploy": "npm run build",
"deploy": "gh-pages -d build"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/jvallelunga/redux-bluetooth.git"
},
"keywords": [
"redux-bluetooth",
"example",
"counter",
"webapp"
],
"author": "Jeronimo Vallelunga",
"license": "ISC",
"bugs": {
"url": "https://github.com/jvallelunga/redux-bluetooth/issues"
},
"homepage": "https://jvallelunga.github.io/redux-bluetooth",
"dependencies": {
"prop-types": "^15.5.10",
"react": "^15.6.1",
"react-dom": "^15.6.1",
"react-redux": "^5.0.5",
"redux": "^3.7.1",
"redux-bluetooth": "file:../../",
"redux-thunk": "^2.2.0"
},
"devDependencies": {
"gh-pages": "^1.0.0",
"react-scripts": "1.0.10"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "192x192",
"type": "image/png"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,9 @@
import * as TYPES from './types';
export function increment() {
return { type: TYPES.INCREMENT };
}
export function decrement() {
return { type: TYPES.DECREMENT };
}

View File

@ -0,0 +1,2 @@
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';

View File

@ -0,0 +1,58 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import './style.css';
export default class App extends PureComponent {
constructor(props) {
super(props);
this.handlerConnect = this.handlerConnect.bind(this);
}
handlerConnect(){
const { onConnect } = this.props;
onConnect('Counter');
}
render() {
const {
store, status,
onIncrement, onDecrement
} = this.props;
return (
<div className="app">
{ (status === 'CONNECTED') &&
<div className="app-counter">{store}</div>
}
<div className="app-actions">
{ (status !== 'CONNECTED') &&
<button className="app-actions__buton" onClick={this.handlerConnect}>Connect</button>
}
{ (status === 'CONNECTED') &&
<button className="app-actions__buton" onClick={onIncrement}>+</button>
}
{ (status === 'CONNECTED') &&
<button className="app-actions__buton" onClick={onDecrement}>-</button>
}
</div>
</div>
);
}
};
App.propTypes = {
store: PropTypes.number,
status: PropTypes.string,
onConnect: PropTypes.func,
onIncrement: PropTypes.func,
onDecrement: PropTypes.func,
};
App.defaultProps = {
onConnect: () => true,
onIncrement: () => true,
onDecrement: () => true,
};

View File

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import { actions } from 'redux-bluetooth/build/webapp';
import { increment, decrement } from '../actions';
import Component from './component';
const mapState = (state) => {
return state;
}
const mapAction = {
onConnect: actions.connectStore,
onIncrement: increment,
onDecrement: decrement,
};
export { Component };
export default connect(mapState, mapAction)(Component);

View File

@ -0,0 +1,28 @@
.app {
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
align-items: center;
padding: 50px;
box-sizing: border-box;
}
.app-counter {
margin-bottom: 40px;
line-height: calc(100vh - 200px);
font-size: 80vh;
text-align: center;
}
.app-actions {
height: 30px;
display: flex;
justify-content: center;
width: 90%;
}
.app-actions__buton {
flex: 1;
margin: 0 20px;
}

View File

@ -0,0 +1,5 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}

View File

@ -0,0 +1,21 @@
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { createSyncStore } from 'redux-bluetooth/build/webapp';
import './index.css';
import * as TYPES from './actions/types';
import App from './app';
const ACTIONS = Object.keys(TYPES);
const store = createSyncStore(ACTIONS);
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "redux-bluetooth",
"version": "0.0.0",
"description": "Redux middleware to dispatch actions via bluetooth to a peripheral store",
"main": "build/index.js",
"scripts": {
"dev": "watch 'npm run build' src",
"build": "babel src -d build",
"test": "jest",
"test:watch": "npm test -- --watch",
"prepublish": "npm run build",
"release": "np"
},
"babel": {
"presets": [
"latest"
]
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/jvallelunga/redux-bluetooth.git"
},
"keywords": [
"redux",
"middleware",
"bluetooth",
"iot",
"bleno",
"peripheral",
"web-bluetooth",
"central"
],
"author": "Jeronimo Vallelunga",
"license": "ISC",
"bugs": {
"url": "https://github.com/jvallelunga/redux-bluetooth/issues"
},
"homepage": "https://github.com/jvallelunga/redux-bluetooth#readme",
"devDependencies": {
"babel-cli": "^6.24.1",
"babel-preset-latest": "^6.24.1",
"jest": "^20.0.4",
"np": "^2.16.0",
"watch": "^1.0.2"
},
"dependencies": {
"bleno": "^0.4.2",
"text-encoding": "^0.6.4"
}
}

View File

@ -0,0 +1,11 @@
const CONFIG = {
SERVICE_UUID: '13333333-3333-3333-3333-333333333337',
CHARACTERISTIC_UUID: '13333333-3333-3333-3333-333333330001'
};
export const CENTRAL_CONFIG = CONFIG;
export const BLENO_CONFIG = {
SERVICE_UUID: CONFIG.SERVICE_UUID.replace(/-/g, ''),
CHARACTERISTIC_UUID: CONFIG.CHARACTERISTIC_UUID.replace(/-/g, ''),
DESCRIPTOR_UUID: '2901'
};

View File

@ -0,0 +1,17 @@
export default function Encoder({ TextEncoder, TextDecoder}) {
const encoder = new TextEncoder('utf-8');
const decoder = new TextDecoder('utf-8');
const encode = (json) => {
const string = JSON.stringify(json);
return encoder.encode(string);
}
const decode = (data) => {
const string = decoder.decode(data);
const json = JSON.parse(string);
return typeof json === 'string' ? JSON.parse(json) : json;
}
return { encode, decode };
}

View File

@ -0,0 +1,12 @@
import TextEncoding from 'text-encoding';
import Encoder from '.';
const { encode , decode } = new Encoder(TextEncoding);
test('encode / decode', () => {
const data = encode({ type: 'TEST', payload: 'PAYLOAD' });
const result = decode(data);
expect(result).toEqual({ type: 'TEST', payload: 'PAYLOAD' });
});

4
src/index.js Normal file
View File

@ -0,0 +1,4 @@
import * as peripheral from './peripheral';
import * as webapp from './webapp';
export default { peripheral, webapp };

View File

@ -0,0 +1,67 @@
export default function Characteristic(
uuid,
Parent,
util,
descriptor,
{ encode, decode } ) {
function ReduxCharacteristic() {
Parent.call(this, {
uuid,
properties: ['read', 'write', 'notify'],
descriptors: [ descriptor ]
});
this.store = null;
}
util.inherits(ReduxCharacteristic, Parent);
ReduxCharacteristic.prototype.connect = function(store) {
this.store = store;
this.store.subscribe(() => {
if ( this.updateValueCallback && this.store ) {
const state = this.store.getState();
this.updateValueCallback(encode(state));
}
});
}
ReduxCharacteristic.prototype.disconnect = function() {
this.store = null;
}
ReduxCharacteristic.prototype.onWriteRequest = function(data, offset, withoutResponse, callback) {
if (offset) {
callback(this.RESULT_ATTR_NOT_LONG);
return;
}
this.store && this.store.dispatch(decode(data));
callback(this.RESULT_SUCCESS);
};
ReduxCharacteristic.prototype.onReadRequest = function(offset, callback) {
if (offset) {
callback(this.RESULT_ATTR_NOT_LONG, null);
return;
}
if ( !this.store ) {
callback(this.RESULT_SUCCESS, null);
return;
}
callback(this.RESULT_SUCCESS, encode(this.store.getState()));
};
ReduxCharacteristic.prototype.onSubscribe = function(maxValueSize, updateValueCallback) {
this.updateValueCallback = updateValueCallback;
}
ReduxCharacteristic.prototype.onUnsubscribe = function() {
this.updateValueCallback = null;
}
return new ReduxCharacteristic();
}

View File

@ -0,0 +1,3 @@
export default function Descriptor(uuid, Parent) {
return new Parent({ uuid, value: 'Redux Characteristic.' });
}

View File

@ -0,0 +1,62 @@
import util from 'util';
import bleno from 'bleno';
import TextEncoding from 'text-encoding';
import { BLENO_CONFIG } from '../../common/config';
import Encoder from '../../common/encoder';
import Service from './service';
import Characteristic from './characteristic';
import Descriptor from './Descriptor';
export function Bleno(
bleno,
encoder,
{ SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID }) {
const descriptor = Descriptor(
DESCRIPTOR_UUID,
bleno.Descriptor
);
const characteristic = Characteristic(
CHARACTERISTIC_UUID,
bleno.Characteristic,
util,
descriptor,
encoder);
const service = Service(
SERVICE_UUID,
bleno.PrimaryService,
util,
characteristic);
const start = (name, store) => {
bleno.on('stateChange', function(state) {
if (state === 'poweredOn') {
bleno.startAdvertising(name, [SERVICE_UUID], function(err) {
err && console.log('startAdvertising.err: ', err);
});
} else {
bleno.stopAdvertising();
characteristic.disconnect();
}
});
bleno.on('advertisingStart', function(err) {
if (!err) {
bleno.setServices([ service ]);
characteristic.connect(store);
}
});
}
return { start };
}
export default new Bleno(
bleno,
Encoder(TextEncoding),
BLENO_CONFIG
);

View File

@ -0,0 +1,16 @@
export default function Service(
uuid,
Parent,
util,
characteristic) {
function ReduxService() {
Parent.call(this, {
uuid,
characteristics: [characteristic],
});
}
util.inherits(ReduxService, Parent);
return new ReduxService();
}

3
src/peripheral/index.js Normal file
View File

@ -0,0 +1,3 @@
import bleno from './bleno';
export const startPeripheral = (name, store) => bleno.start(name, store);

View File

@ -0,0 +1,31 @@
export default function Actions(central, TYPES) {
const syncState = (state) => ({
type: TYPES.BLUETOOTH_SYNC,
payload: state
});
const connectStore = (name) => dispatch => {
dispatch({ type: TYPES.BLUETOOTH_CONNECTING });
return central.connect(name)
.then(() => central.handler((state) => dispatch(syncState(state))))
.then(() => dispatch({ type: TYPES.BLUETOOTH_CONNECTED }))
.then(() => dispatch(syncStore()));
};
const syncStore = () => dispatch => {
return central.read()
.then(state => dispatch(syncState(state)));
};
const sendAction = (action) => _ => {
return central.write(action);
};
return {
connectStore,
syncStore,
syncState,
sendAction,
}
}

View File

@ -0,0 +1,75 @@
import * as TYPES from './types';
import Actions from './actions';
let dispatch = null;
let central = null;
beforeEach(() => {
dispatch = jest.fn();
central = {
connect: jest.fn().mockReturnValue(Promise.resolve()),
handler: jest.fn(),
read: jest.fn().mockReturnValue(Promise.resolve('mockState')),
write: jest.fn().mockReturnValue(Promise.resolve()),
};
});
afterEach(() => {
dispatch = null;
central = null;
});
test('syncState', () => {
const { syncState } = Actions(central, TYPES);
const action = syncState('mockState');
expect(action).toEqual({
type: TYPES.BLUETOOTH_SYNC,
payload: 'mockState'
});
});
test('connectStore', () => {
const { connectStore } = Actions(central, TYPES);
expect.assertions(6);
const promise = connectStore('mockName')(dispatch).then(_ => {
expect(central.connect).toBeCalled();
expect(central.handler).toBeCalled();
expect(dispatch.mock.calls.length).toBe(2);
expect(dispatch.mock.calls[0][0]).toEqual({ type: TYPES.BLUETOOTH_CONNECTING });
expect(dispatch.mock.calls[1][0]).toEqual({ type: TYPES.BLUETOOTH_CONNECTED });
return true;
});
return expect(promise).resolves.toBe(true);
});
test('syncStore', () => {
const { syncStore } = Actions(central, TYPES);
expect.assertions(3);
const promise = syncStore()(dispatch).then(_ => {
expect(central.read).toBeCalled();
expect(dispatch).toBeCalledWith({ type: TYPES.BLUETOOTH_SYNC, payload: 'mockState' });
return true;
});
return expect(promise).resolves.toBe(true);
});
test('sendAction', () => {
const { sendAction } = Actions(central, TYPES);
expect.assertions(2);
const promise = sendAction('mockAction')(dispatch).then(_ => {
expect(central.write).toBeCalledWith('mockAction');
return true;
});
return expect(promise).resolves.toBe(true);
});

View File

@ -0,0 +1,6 @@
import central from '../central';
import * as TYPES from './types';
import Actions from './actions';
export default Actions(central, TYPES);

View File

@ -0,0 +1,6 @@
export const BLUETOOTH_CONNECTING = '@@bluetooth/CONNECTING';
export const BLUETOOTH_CONNECTED = '@@bluetooth/CONNECTED';
export const BLUETOOTH_ERROR = '@@bluetooth/ERROR';
export const BLUETOOTH_READ = '@@bluetooth/READ';
export const BLUETOOTH_SYNC = '@@bluetooth/SYNC';
export const BLUETOOTH_SEND = '@@bluetooth/SEND';

View File

@ -0,0 +1,69 @@
import { CENTRAL_CONFIG } from '../../common/config';
import Encoder from '../../common/encoder';
export function Central(
bluetooth,
{ encode, decode },
{ SERVICE_UUID, CHARACTERISTIC_UUID }) {
const state = {
server: null,
characteristic: null,
}
const connect = (name) => {
return bluetooth.requestDevice({
filters: [{ services: [ SERVICE_UUID ], name: name }]
})
.then((device) => device.gatt.connect())
.then((server) => {
state.server = server;
return server.getPrimaryService(SERVICE_UUID)
})
.then((service) => service.getCharacteristic(CHARACTERISTIC_UUID))
.then((characteristic) => {
state.characteristic = characteristic;
});
};
const handler = ( callback ) => {
return state.characteristic.startNotifications()
.then(() => {
state.characteristic.addEventListener('characteristicvaluechanged', (event) => {
callback(decode(event.target.value));
});
});
}
const read = () => {
if ( state.server && state.server.connected && state.characteristic ) {
return state.characteristic.readValue().then(data => {
return decode(data);
});
}
return Promise.reject(new Error('Bluetooth: Not Connected'));
}
const write = (action) => {
if ( state.server && state.server.connected && state.characteristic ) {
const stringify = JSON.stringify(action);
const serialized = encode(stringify);
return state.characteristic.writeValue(serialized);
}
}
return {
connected: state.server && state.server.connected,
connect,
handler,
read,
write
}
}
export default new Central(
navigator.bluetooth,
Encoder({ TextEncoder, TextDecoder }),
CENTRAL_CONFIG
);

View File

@ -0,0 +1,6 @@
export const INIT = 'INIT';
export const DISCONNECTED = 'DISCONNECTED';
export const DISCONNECTING = 'DISCONNECTING';
export const CONNECTING = 'CONNECTING';
export const CONNECTED = 'CONNECTED';
export const ERROR = 'ERROR';

15
src/webapp/index.js Normal file
View File

@ -0,0 +1,15 @@
import * as TYPES from './actions/types';
import * as STATUS from './central/status';
import ACTIONS from './actions';
import MIDDLEWARE from './middleware';
import REDUCERS from './reducers';
import STORE from './store';
const { connectStore, syncStore } = ACTIONS;
export const types = TYPES;
export const status = STATUS;
export const actions = { connectStore, syncStore };
export const reducers = REDUCERS;
export const middleware = MIDDLEWARE;
export const createSyncStore = STORE;

View File

@ -0,0 +1,8 @@
import actions from '../actions';
const { sendAction } = actions;
export default (actions = []) => store => next => action => {
const { type } = action;
actions.includes(type) && store.dispatch(sendAction(action));
return next(action);
}

View File

@ -0,0 +1,19 @@
import * as TYPES from '../actions/types';
import * as STATUS from '../central/status';
const initial = {
status: STATUS.INIT
};
export default (autosync = true) => (state = initial, { type, payload }) => {
switch (type) {
case TYPES.BLUETOOTH_CONNECTING:
return Object.assign({}, state, { status: STATUS.CONNECTING });
case TYPES.BLUETOOTH_CONNECTED:
return Object.assign({}, state, { status: STATUS.CONNECTED });
case TYPES.BLUETOOTH_SYNC:
return autosync ? Object.assign({}, state, { store: payload }) : state;
default:
return state;
}
};

24
src/webapp/store/index.js Normal file
View File

@ -0,0 +1,24 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import middleware from '../middleware';
import reducers from '../reducers';
export default (actions) => {
const middlewares = [
middleware(actions),
thunk
];
const enhancers = [
applyMiddleware(...middlewares),
];
if (typeof window === 'object' && window.__REDUX_DEVTOOLS_EXTENSION__) {
enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__());
}
return createStore(
reducers(),
compose(...enhancers),
);
}