Commit 6d568a8f authored by Matija Obreza's avatar Matija Obreza
Browse files

BT: Electronic balance reader

Use Bluetooth-enabled electronic balances to capture weights directly into GGCE!
parent 1fd641e4
...@@ -539,6 +539,14 @@ ...@@ -539,6 +539,14 @@
"loading": "Loading inventory data", "loading": "Loading inventory data",
"scheduleInventoryAction": "Schedule inventory action" "scheduleInventoryAction": "Schedule inventory action"
}, },
"balance": {
"deviceSelected": "Bluetooth device {{name}} selected.",
"connecting": "Connecting {{name}}...",
"connected": "Connected to {{name}}.",
"disconnecting": "Disconnecting from {{name}}...",
"disconnected": "Disconnected.",
"error": "Bluetooth error: {{message}}"
},
"public": { "public": {
"p": { "p": {
"actions": { "actions": {
......
...@@ -16,6 +16,14 @@ ...@@ -16,6 +16,14 @@
"loading": "Loading inventory data", "loading": "Loading inventory data",
"scheduleInventoryAction": "Schedule inventory action" "scheduleInventoryAction": "Schedule inventory action"
}, },
"balance": {
"deviceSelected": "Bluetooth device {{name}} selected.",
"connecting": "Connecting {{name}}...",
"connected": "Connected to {{name}}.",
"disconnecting": "Disconnecting from {{name}}...",
"disconnected": "Disconnected.",
"error": "Bluetooth error: {{message}}"
},
"public": { "public": {
"p": { "p": {
"actions": { "actions": {
......
...@@ -26,6 +26,7 @@ import { withUrlNavigation } from '_core/routing/withPageNavigation'; ...@@ -26,6 +26,7 @@ import { withUrlNavigation } from '_core/routing/withPageNavigation';
import { compose } from 'redux'; import { compose } from 'redux';
import BarcodeScannerButton from 'ui/barcode/BarcodeScanner'; import BarcodeScannerButton from 'ui/barcode/BarcodeScanner';
import { Accession } from '@gringlobal-ce/client/model/gringlobal'; import { Accession } from '@gringlobal-ce/client/model/gringlobal';
import BalanceReader from './c/BalanceReader';
export const InventoryTableConfig = new TableConfiguration({ export const InventoryTableConfig = new TableConfiguration({
defaultColumns: [ defaultColumns: [
...@@ -78,9 +79,15 @@ const calculator = createDecorator( ...@@ -78,9 +79,15 @@ const calculator = createDecorator(
}, },
); );
const Condition = ({ when, is, children }: { when: string, is: string } & React.ComponentProps<any>) => ( const Condition = ({ when, is, match, children }: { when: string } & ({ is: string, match: never } | { is: never, match: RegExp }) & React.ComponentProps<any>) => (
<Field name={ when } subscription={ { value: true } }> <Field name={ when } subscription={ { value: true } }>
{({ input: { value } }) => (value === is ? children : null)} {({ input: { value } }) => is !== undefined ? (value === is ? children : null) : (value.match(match) ? children : null)}
</Field>
);
const ConditionNot = ({ when, isNot, notMatch, children }: { when: string } & ({ isNot: string, notMatch: never } | { isNot: never, notMatch: RegExp }) & React.ComponentProps<any>) => (
<Field name={ when } subscription={ { value: true } }>
{({ input: { value } }) => isNot !== undefined ? (value === isNot ? null : children) : (value.match(notMatch) ? null : children)}
</Field> </Field>
); );
...@@ -257,16 +264,16 @@ class AdjustQuantity extends React.Component<{ showSnackbar: (msg: string) => vo ...@@ -257,16 +264,16 @@ class AdjustQuantity extends React.Component<{ showSnackbar: (msg: string) => vo
<Grid container spacing={ 2 }> <Grid container spacing={ 2 }>
<Condition when="quantityOnHandUnitCode" is={ 'sd' }> <Condition when="quantityOnHandUnitCode" is={ 'sd' }>
<Grid item xs={ 12 } sm={ 8 }> <Grid item xs={ 12 } sm={ 8 }>
<Field <BalanceReader
disabled={ quantityDisabled } label={ t('inventory.adjust.currentWeight') }
placeholder={ t('inventory.adjust.currentWeight') } placeholder={ t('inventory.adjust.currentWeight') }
name="weight" name="weight"
type="text" type="number"
tabIndex="1" tabIndex="1"
autoFocus autoFocus
validate={ validatePositiveNumber } validate={ validatePositiveNumber }
component={ TextField } disabled={ quantityDisabled }
label={ t('inventory.adjust.currentWeight') } // component={ TextField }
/> />
</Grid> </Grid>
<Grid item xs={ 12 } sm={ 4 }> <Grid item xs={ 12 } sm={ 4 }>
...@@ -283,18 +290,33 @@ class AdjustQuantity extends React.Component<{ showSnackbar: (msg: string) => vo ...@@ -283,18 +290,33 @@ class AdjustQuantity extends React.Component<{ showSnackbar: (msg: string) => vo
</Grid> </Grid>
</Condition> </Condition>
<Grid item xs={ 12 } sm={ 8 }> <Grid item xs={ 12 } sm={ 8 }>
<Field <Condition when="quantityOnHandUnitCode" match={ /^(gm|g)$/ }>
placeholder={ t(['client:model.Inventory.quantityOnHand', 'client:model._.quantityOnHand']) } <BalanceReader
disabled={ quantityDisabled } placeholder={ t(['client:model.Inventory.quantityOnHand', 'client:model._.quantityOnHand']) }
name="quantityOnHand" disabled={ quantityDisabled }
type="text" name="quantityOnHand"
tabIndex="2" type="text"
component={ TextField } tabIndex="2"
inputRef={ this.inputQuantity } inputRef={ this.inputQuantity }
required required
validate={ validatePositiveNumber } validate={ validatePositiveNumber }
label={ t(['client:model.Inventory.quantityOnHand', 'client:model._.quantityOnHand']) } label={ t(['client:model.Inventory.quantityOnHand', 'client:model._.quantityOnHand']) }
/> />
</Condition>
<ConditionNot when="quantityOnHandUnitCode" notMatch={ /^(gm|g)$/ }>
<Field
placeholder={ t(['client:model.Inventory.quantityOnHand', 'client:model._.quantityOnHand']) }
disabled={ quantityDisabled }
name="quantityOnHand"
type="text"
tabIndex="2"
component={ TextField }
inputRef={ this.inputQuantity }
required
validate={ validatePositiveNumber }
label={ t(['client:model.Inventory.quantityOnHand', 'client:model._.quantityOnHand']) }
/>
</ConditionNot>
</Grid> </Grid>
<Grid item xs={ 12 } sm={ 4 }> <Grid item xs={ 12 } sm={ 4 }>
<Field <Field
......
/**
* A small UI component to read weight from BTLE-connected
* weighing balances.
*/
import * as React from 'react';
import { Field, useField } from 'react-final-form';
import { TextField } from '@gringlobal-ce/client/ui/common/form/TextField';
import { useTranslation } from 'react-i18next';
import { IconButton, InputAdornment } from '@material-ui/core';
import BluetoothSearchingIcon from '@material-ui/icons/Bluetooth';
import BluetoothConnectedIcon from '@material-ui/icons/BluetoothConnected';
import { useDispatch } from 'react-redux';
import { showSnackbar } from '@gringlobal-ce/client/action/snackbar';
declare const navigator: any;
const BalanceBTButton = ({ onReceiveWeight }: { onReceiveWeight: (weight: number | '') => unknown }) => {
const BLUETOOTH_SERIAL_PORT = 0x1101;
const BLUETOOTH_CLASSIC_SPP = '00001101-0000-1000-8000-00805f9b34fb';
const BLUETOOTH_BTS5_LE_SERVICE = '0000b350-d6d8-c7ec-bdf0-eab1bfc6bcbc';
const BLUETOOTH_BTS5_R_CHARACTERISTIC = '0000b351-d6d8-c7ec-bdf0-eab1bfc6bcbc';
const { t } = useTranslation();
const [ balanceDevice, setBalanceDevice ] = React.useState<any>();
const myCharacteristic = React.useRef(null);
const [ busyIndicator, setBusyIndicator ] = React.useState(false);
const dec = new TextDecoder(); // always utf-8
const dispatch = useDispatch();
const handleCharacteristicNotification = (event) => {
// console.log(event.target.value);
const data = dec.decode(event.target.value);
// console.log('Data', data);
const val = data.match(/^([+-] *\d+\.\d+)g *$/);
if (val) {
// console.log(`Weight: ${val[1]}`);
try {
const weight = parseFloat(val[1].replace(/ +/g, ''));
if (onReceiveWeight) {
onReceiveWeight(weight);
}
} catch (e) {
console.log(`Error reporting weight "${val[1]}": ${e}`);
}
}
}
const disconnect = () => {
if (myCharacteristic.current) {
return myCharacteristic.current.stopNotifications()
.then(_ => {
console.log('Notifications stopped', _);
myCharacteristic.current.removeEventListener('characteristicvaluechanged', handleCharacteristicNotification);
myCharacteristic.current.service.device.gatt.disconnect();
})
.catch(error => {
console.log('Argh! ' + error);
})
.finally(() => {
myCharacteristic.current = null;
})
}
return Promise.resolve();
}
// effect
React.useEffect(() => {
console.log('Effecting Balance reader');
if (typeof navigator['bluetooth'] === 'undefined') {
console.log('Not in a Chrome browser');
return;
}
navigator.bluetooth.getAvailability().then(available => {
const preferredBalanceId = localStorage.getItem('preferredBalance');
console.log(`Bluetooth availability: ${available}, looking for ${preferredBalanceId}`);
navigator.bluetooth.getDevices().then(d => {
console.log('We have access to', d);
const preferredDevice = d.find(device => device.id === preferredBalanceId);
if (preferredDevice) {
setBalanceDevice(preferredDevice);
} else {
throw new Error('No preferred device!');
}
}).catch(err => {
console.log('Error retrieving preferred balance device', err);
});
});
return () => {
console.log('We are done here!', myCharacteristic.current);
disconnect();
}
}, []);
const connectBalance = () => {
navigator.bluetooth.requestDevice({ acceptAllDevices:true, optionalServices: [ BLUETOOTH_SERIAL_PORT, BLUETOOTH_CLASSIC_SPP, BLUETOOTH_BTS5_LE_SERVICE ] }).then(device => {
console.log('Got device', device);
localStorage.setItem('preferredBalance', device.id);
setBalanceDevice(device);
dispatch(showSnackbar(t('inventory.balance.deviceSelected', { name: device.name })));
});
}
const readBalance = () => {
const device = balanceDevice;
if (! device) {
return;
}
if (device.gatt.connected) {
console.log('Device is already connected');
dispatch(showSnackbar(t('inventory.balance.disconnecting', { name: device.name })));
setBusyIndicator(true);
disconnect().then(() => {
dispatch(showSnackbar(t('inventory.balance.disconnected')));
}).finally(() => {
setBusyIndicator(false);
});
return;
}
dispatch(showSnackbar(t('inventory.balance.connecting', { name: device.name })));
setBusyIndicator(true);
device.gatt.connect()
.then(server => {
console.log('GATT server', server);
server.getPrimaryServices().then(x => {
console.log('Primary services', x);
});
return server.getPrimaryService(BLUETOOTH_BTS5_LE_SERVICE);
}).then(service => {
console.log('Got service', service);
service.getCharacteristics({}).then(characteristics => {
console.log('Service characteristic', characteristics);
});
return service.getCharacteristic(BLUETOOTH_BTS5_R_CHARACTERISTIC);
}).then(readCharacteristic => {
console.log('Connected to BT characteristic', readCharacteristic);
myCharacteristic.current = readCharacteristic;
return readCharacteristic.startNotifications().then(_ => {
console.log('> Notifications started', _);
dispatch(showSnackbar(t('inventory.balance.connected', { name: readCharacteristic.service.device.name })));
readCharacteristic.addEventListener('characteristicvaluechanged', handleCharacteristicNotification);
});
}).catch(err => {
console.log(`Error: ${err}`, err);
dispatch(showSnackbar(t(['inventory.balance.error'], { message: `${err}` })));
setBalanceDevice(null);
myCharacteristic.current = null;
}).finally(() => {
setBusyIndicator(false);
});
}
if (typeof navigator['bluetooth'] === 'undefined') {
return null;
}
if (! balanceDevice) {
return (
<InputAdornment position="end"><IconButton onClick={ connectBalance }><BluetoothSearchingIcon /></IconButton></InputAdornment>
)
}
// We have a device!
return (
<InputAdornment position="end"><IconButton disabled={ busyIndicator } onClick={ readBalance }><BluetoothConnectedIcon style={{ color: myCharacteristic.current !== null ? 'green' : 'red' }} /></IconButton></InputAdornment>
)
}
const BalanceReader = ({ ...rest }: { component: never } & any) => {
// const { t } = useTranslation();
// const inputRef = React.useRef(null);
const field = useField(rest.name);
const updateWeight = (weight) => {
// console.log(`Received weight ${weight} for field "${rest.name}"`, field);
field.input.onChange(weight);
// inputRef.current.value = weight;
}
return (
<Field
// inputRef={ inputRef }
component={ TextField }
endAdornment={ <BalanceBTButton onReceiveWeight={ updateWeight } /> }
{ ...rest }
/>
)
}
export { BalanceReader as default };
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment