Заказчик был заинтересован в системе, которая упрощала бы контроль за состоянием оборудования на предприятиях отрасли. Существующая процедура контроля представляет собой регулярные обходы оборудования установленного в цехах (по маршрутам) и заполнение бумажных бланков, с указанием в них показаний приборов и внешних признаков неисправностей.
Поскольку полная автоматизация (с контролем всех параметров датчиками и передачей данных с них по сети) технически и организационно невозможна, предлагалось частично автоматизировать процедуру. А именно — в местах проверок (контрольных точках) повесить NFC метки, а сотрудникам, осуществляющим обход, выдать смартфоны с приложением, которое фиксирует прикладывание смартфона к метке и предлагает ввести требуемые для каждой точки параметры. Данные затем передаются на сервер, откуда могут быть просмотрены руководителем в браузере (с отметками выхода за пределы допустимых значений, статистикой и пр).
Для реализации был выбран Javascript, а конкретно — связка в виде NodeJS, React+Redux, React-Native (с обменом между ними через GraphQL). Это позволило использовать один язык для сервера, клиента и мобильного приложения. Прежде с перечисленными технологиями (кроме Javascript) я вплотную не сталкивался, поэтому статья во многом отражает опыт знакомства с ними.
Функционал системы и организация БД
Сущности и их взаимосвязь
'checkpoint' — Контрольная точка, эквивалентна единице оборудования
'param' — Параметры контрольной точки
'unit' — Единица измерения параметра
'range' — Диапазон допустимых для данного параметра значений
'checkup' — Факт обхода контрольных точек по одному из маршрутов
'check' — Факт проверки контрольной точки в ходе обхода (содержит замечания по точке)
'checkvalue' — Фактическое значения одного параметра при проверке контрольной точки
'firm' — Компания
'plant' — Предприятие компании
'shop' — Цех предприятия
'route' — Маршрут по цеху
'rclink' — Привязка контрольной точки к маршруту (определяет порядок посещения точек на данном маршруте )
'position' — Должность пользователя
'user' — Пользователь
Привязка контрольных точек к маршрутам выполняется не напрямую, а через промежуточную rclinks. Это связано с тем, что в рамках одного цеха может быть несколько маршрутов, причём по одним и тем же контрольным точкам — на разных маршрутах, в разном порядке, и необязательно по всем. Поэтому, здесь используется двунаправленный список.
Все описанные сущности представлены в базе данных одноименными таблицами (во множественном числе — т.е. routes, checkpoints и т.д). Идентификаторы записей являются глобальными и генерируются через node-uuid на сервере (в React Native — через react-native-uuid) с добавлением типа (для удобства отладки)
Например: 0ec74560-9da3-11e7-b19a-b5183ad4a4a8_checkpoint
Для каждой записи во всех таблицах фиксируется дата создания записи, дата её изменения (dt_crt, dt_mod), дата последней синхронизации (dt_sync), идентификатор сессии синхронизации (sync_id), а также id пользователя, создавшего или изменившегося запись (user_crt_id, user_mod_id).
Структуры баз на сервере и в мобильном приложении — идентичны (по этой причине для серверной части был выбран SQLite).
Перед началом работы в веб-интерфейсе создаются организации и их структура. Высшим уровнем иерархии является организация (firm), которая содержит производственные объекты (plants). Каждый объект имеет в своём составе цеха (shops). Уровень цехов технически является основным — к ним разрешается доступ должностям (positions), добавляются контрольные точки, в рамках цехов имеются маршруты (routes) и т.д.
Пользователи (users) технически лишь обеспечивают возможность пользоваться правами должности и могут иметь различные настройки интерфейса.
Контрольные точки
Ключевой сущностью системы является понятие контрольной точки (checkpoint). Смыслом обхода (checkup) является посещение контрольных точек и снятие значений параметров размещённого на них оборудования (checkvalue) для последующего хранения и оценки.
Первоначальное добавление контрольной точки осуществляется в контексте заданного цеха (shop), через веб интерфейс. При этом NFC тэг в форму вносится путем касания смартфоном NFC метки.
Также, в процессе добавления точки к ней привязываются один или несколько параметров (params) и допустимые диапазоны значений (ranges). К параметрам — единицы их измерения (units), которые также привязаны к организации (firms). Т.е. каждая организация может иметь собственный набор единиц измерения.
Параметры могут быть двух типов — float (число) и boolean (значение вида да/нет). Третий тип (text) зарезервирован.
Для каждого параметра должны быть указаны минимальное и максимальное допустимые значения. При показе результатов обхода значения вне диапазона будут выделены красным.
Созданные контрольные точки могут быть привязаны к одному или нескольким маршрутам (route) в заданном, для каждого маршрута, порядке. Т.е. одна и та же точка может быть составной частью разных маршрутов.
Порядок задаётся промежуточной таблицей rclinks, где для каждой пары точка-маршрут указан id следующей и предыдущей точки (null для первой и последней).
Процесс обхода
При выполнении обхода мобильное приложение функционирует следующим образом:
Пользователь в предложенном ему, либо выбранном им маршруте нажимает кнопку «Начать обход». При этом создаётся checkup. При выборе контрольной точки пользователь заполняет значениями полученные приложением из params, units поля параметров. При их сохранении, для данной точки в контексте данного обхода создаётся check, в котором сохраняются также замечания по точке и к check привязываются созданные checkvalues, в которых сохраняются значения каждого из введённых параметров.
После сохранения значений параметров точка считается проверенной (т.к. для неё создан check) и ожидается следующая (в соответствии с rclinks.next_id) точка на данном маршруте.
При этом, уже проверенные точки допускается редактировать повторно (до окончания обхода). Пропуск же точек не допускается.
Серверная часть (backend)
Серверная часть реализована на NodeJS, SQLite, GraphQL и позволяет:
1. Хранить данные
2. Обеспечивать доступ к данным из мобильного и веб приложений с соответствующим контролем доступа
Для сервера используется express (с ним стыкуются graphql-server-express и express-jwt). SQLite — через node-sqlite (поддерживает промисы).
Сервер состоит из schema.js, где описываются типы и методы для GraphQL запросов (используется Apollo Server), resolvers.js (с методами, описанными в schema), connectors.js (со всей логикой и SQL запросами к БД) и, собственно, основного server.js
server.js
import express from 'express';
import { graphqlExpress, graphiqlExpress } from 'graphql-server-express';
import bodyParser from 'body-parser';
import cors from 'cors';
import { schema } from './schema';
import { execute, subscribe } from 'graphql';
import { createServer } from 'http';
import { SubscriptionServer } from 'subscriptions-transport-ws';
import db from 'sqlite';
import connectors from './connectors';
import jwt from'express-jwt';
const { GQLSERVERLOCAL, GQLSERVERPROD, WEBSERVER, PATH, DATABASE, AUTH } = require('./config');
let p = db.open(DATABASE.NAME).then(db =>{
console.error("SQLite: DB ok ");
}).catch(error =>
{
console.error("SQLite: DB error: " + error);
});
const server = express();
const corsOptions = {
origin(origin, callback){
callback(null, true);
},
credentials: true
};
server.use(cors(corsOptions));
server.use(PATH.GQL, bodyParser.json(), jwt({
secret: AUTH.secret,
credentialsRequired: false,
getToken: function fromHeaderOrQuerystring (req) {
if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
return req.headers.authorization.split(' ')[1];
} else if (req.query && req.query.token) {
return req.query.token;
}
return null;
}
}), graphqlExpress(req => ({
schema: schema,
context: {
user: req.user ? connectors.findUser(req.user.id) : Promise.resolve(null),
}, // передаётся в context в resolvers.js
})));
// Далее то, что нужно для подписок и обмена сообщениями через websockets
const ws = createServer(server);
server.use(PATH.GIQL, graphiqlExpress({
endpointURL: PATH.GQL,
subscriptionsEndpoint: `ws://${GQLSERVERPROD.HOST}:${GQLSERVERPROD.PORT}/subscriptions`
}));
ws.listen(GQLSERVERPROD.PORT, () => {
console.log(`GraphQL Server v1 is now running on ${GQLSERVERPROD.HOST}:${GQLSERVERPROD.PORT}`);
new SubscriptionServer({
execute,
subscribe,
schema
}, {
server: ws,
path: PATH.SUBS,
});
});
scheme.js
[...]
type DeletePlantResult
{
rows_changed: Int
error: String
}
input IdsInput {
ids: [String]
}
deletePlant(data: IdsInput): DeletePlantResult
[...]
resolvers.js
import connectors from './connectors';
export const resolvers = {
Mutation: {
[...]
deletePlant: (root, params, context) => {
// context содержит то, что в него передано в server.js
[...]
const errors = [];
const {data} = params
const ids = data.ids.map(function(id){
return "'" + id + "'"
}).join(",")
return connectors.deletePlant(ids)
.then(rows_changed => ({
rows_changed,
errors
}))
.catch((err) => {
if (err.code && err.message) {
errors.push({
key: err.code,
value: err.message
});
return { errors };
}
});
},//deletePlant
}
[...]
}
connectors.js
deletePlant(ids) {
return new Promise((resolve, reject) => {
return db.run(`
DELETE FROM 'plants' WHERE id IN(${ids})
`)
.then((plant) => {
resolve(plant.changes);
})
.catch((err) => {
return reject(err);
});
})
},//deletePlant
Реализована двусторонняя неинкрементальная синхронизация данных между сервером и мобильным приложением. От сервера в мобильное приложение передаются
positions, shops, routes, rclinks, checkpoints, ranges, params, units, users
(для синхронизации формируется выборка только записей относящихся к цехам, разрешенным для должности (position) пользователя мобильного приложения). Т.е. состав выборки определяется должностью.
От мобильного приложения на сервер передаются результаты обходов — checkups, checks, checkvalues.
Веб клиент (frontend)
Веб клиент реализован на React + Redux и позволяет:
1. Сформировать в системе организационную структуру организации (промышленные объекты, цеха, пользователи, их должности), схемы и режим обходов (маршруты по цехам, контрольные точки на маршрутах, их параметры)
2. Контролировать режим и результаты проводимых обходов по маршрутам на объектах и в цехах, просматривать внесённые обходчиком значения параметров на контрольных точках, их отклонения от заданных допустимых значений, различную статистику
3. Регистрировать и авторизовывать зарегистрированных пользователей, обеспечивать им доступ к ресурсам
Все страницы представлены React компонентами и делятся на две группы — пользовательские (основной интерфейс) и административные (для контроля, выборочного просмотра и редактирования таблиц БД).
Навигация реализована на react-router — в store хранится текущий контекст, в котором находится пользователь, поэтапно заполняемый вызовами updateContext(). В ссылках передаётся только путь к компоненту (кроме административных страниц, где можно указать id объекта в query string). Из данных контекста вверху каждой страницы строится навигационная строка показывающая, где находится пользователь.
Контекст автоматически сохраняется в local storage через redux-persist.
Вёрстка адаптивная (bootstrap), большинство компонентов страниц в основе имеет таблицу на react-bootstrap-table, ячейки которой заполняются результатами GraphQL запроса, параметрами которого являются id объектов, взятые из контекста и query string.
Там, где необходимо, для ячеек определены custom formatters/renderers (отображение дат, редактирование с выбором из списка и т.п.)
Для GraphQL используется Apollo (и в веб клиенте и в мобильном приложении).
Типовой компонент простой страницы с редактируемой таблицей выглядит примерно так:
components/PlantsPage.js:
import React from 'react';
import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
import { reduxForm } from 'redux-form';
import { Button, ProgressBar } from 'react-bootstrap';
[...]
class PlantsPage extends React.Component
{
addEmptyItem() {
this.props.addPlant({ variables: {data: {firm_id:this.props.params.firm_id, user_crt_id:'system_user',user_mod_id:'system_user'}} });
}
[...]
};
render() {
if (this.props.data.loading)
return <ProgressBar active bsStyle="info" now={100} />;
return (
<div className="container">
<h1>Объекты</h1>
<BootstrapTable data={ this.props.data.getPlants.plants } [...] >
<TableHeaderColumn dataField='title' [...] >Название</TableHeaderColumn>
[...]
</BootstrapTable>
<Button [...] onClick={ () => { this.addEmptyItem(); } }>
Добавить
</Button>
</div>
)
}
}
[...]
export default PlantsPage;
components/PlantsContainer.js:
import React from 'react';
import { withRouter } from 'react-router';
import { connect } from 'react-redux';
import gql from 'graphql-tag';
import { graphql, compose } from 'react-apollo';
import PlantsPage from "../components/PlantsPage";
class PlantsContainer extends React.Component {
render()
{
return <PlantsPage {...this.props} />;
}
}//class
const gqlGetPlants = gql`
query getForPlants($firm_id:String!,$object_id:String!,$holding_id:String!) {
getPlants(firm_id: $firm_id) {
plants {
id,
title,
shops_count,
[...]
},
errors
{
key,
value,
}
},
}
`;
const gqlGetPlantsProps =
{
options: (ownProps) => ({
pollInterval: 0,
variables: {
firm_id: ownProps.params.firm_id,
object_id: ownProps.params.firm_id,
holding_id: 'all',
},
}),
}
const gqlAddPlant = gql`
mutation ($data: PlantInput) {
addPlant(data: $data)
{
id,
errors {
key
value
}
}
}
`;
const gqlAddPlantProps = {
name: 'addPlant',
options: {
refetchQueries: [
'getForPlants',
],
},
};
[...]
const PlantsWithData =
compose(
graphql(gqlUpdatePlant, gqlUpdatePlantProps),
graphql(gqlGetPlants, gqlGetPlantsProps),
[...]
)(withRouter(PlantsContainer));
const PlantsWithDataAndDispatch = connect(
null, // место для mapStateToProps
null, // место для mapDispatchToProps
)(PlantsWithData);
export default PlantsWithDataAndDispatch
Для всплывающих уведомлений и сообщений об ошибках использован react-redux-toastr
Там, где требовались формы (создание контрольной точки, регистрация, логин, редактирование профиля и т.п.) использовался redux-form
Принцип работы с формами следующий:
components/ProfilePage.js:
import React from 'react';
import { Field, reduxForm } from 'redux-form';
import { Button, ControlLabel, FormGroup, Alert } from 'react-bootstrap';
const renderErrors = (errors) => (
<Alert bsStyle="warning">
{errors.map((error, index) => <span key={index}>{error.value}</span>)}
</Alert>
);
class ProfilePage extends React.Component
{
render() {
const errors = this.props.errors <= 0 ? null : renderErrors(this.props.errors)
const { handleSubmit } = this.props;
return (
<div className="container">
<form onSubmit={handleSubmit} >
<FormGroup controlId="firstname">
<ControlLabel>Имя:</ControlLabel>
<Field className="form-control" id="firstname" name="firstname" type="text" component="input"
placeholder="Ваше имя (например: 'Иван')"/>
</FormGroup>
[...]
{errors}
<Button type="submit" bsStyle="primary">
Сохранить
</Button>
</form>
</div>
)
}
}
ProfilePage = reduxForm({
form: 'ProfileForm',
enableReinitialize: true,
})(ProfilePage);
// enableReinitialize: true, - если нужно менять значения в полях
export default ProfilePage;
containers/ProfileContainer.js:
import React from 'react';
import { withRouter } from 'react-router';
import { connect } from 'react-redux';
import gql from 'graphql-tag';
import { graphql, compose } from 'react-apollo';
import ProfilePage from '../components/ProfilePage';
import { toastr } from 'react-redux-toastr'
const jwtDecode = require('jwt-decode');
class ProfileContainer extends React.Component {
constructor(props) {
super(props);
this.state = { errors: [] };
}
handleSubmit(data) {
this.props.updateProfile({ variables: {
data: {
firstname: data.firstname,
[...]
},
id: jwtDecode(localStorage.getItem('token')).id
}})
.then((response) => {
this.setState({
errors: []
});
if (response.data.updateProfile.errors.length <= 0) {
toastr.success('*', 'Данные профиля сохранены', {showCloseButton: false})
} else {
this.setState({
errors: response.data.updateProfile.errors
});
}
})
.catch((err) => {
console.error(err);
toastr.error('*', 'Ошибка при сохранении профиля', {showCloseButton: false})
});
}//handleSubmit
render()
{
return <ProfilePage {...this.props} onSubmit={this.handleSubmit.bind(this)} errors={this.state.errors} />;
}
}//class
const gqlGetProfile = gql`
query getProfile ($user_id: String!) {
getProfile(user_id: $user_id) {
profile {
id,
firstname,
[...]
},
errors
{
key,
value
},
}
}
`;
const gqlGetProfileProps =
{
options: (ownProps) => ({
variables: {
user_id: jwtDecode(localStorage.getItem('token')).id,
},
}),
props: ({ ownProps, data }) => {
if (data.loading) {
return {
initialValues: { test: [] },
errors: []
};
};
return {
initialValues: data.getProfile.profile,
errors: data.getProfile.errors,
};
}
}
const gqlUpdateProfile = gql`
mutation ($data: ProfileInput, $id : String) {
updateProfile(data: $data, id : $id)
{
token,
errors
{
key,
value
},
}
}
`;
const gqlUpdateProfileProps = {
name: 'updateProfile',
options: {
refetchQueries: [
'getProfile',
],
},
};
const ProfileWithData =
compose(
graphql(gqlUpdateProfile, gqlUpdateProfileProps),
graphql(gqlGetProfile, gqlGetProfileProps),
)(withRouter(ProfileContainer));
const ProfileWithDataAndDispatch = connect(
null,
null
)(ProfileWithData);
export default ProfileWithDataAndDispatch
Мобильное приложение
Мобильное приложение реализовано для смартфонов Android 4.4+ (NFC), с использованием React-Native, Redux, SQLite.
Приложение позволяет:
1. Проводить обходы — т.е. фиксировать посещения заданных контрольных точек маршрута при помощи бесконтактных NFC меток и сохранять в базе связанные с ними параметры и замечания
2. Синхронизировать данные — получать с сервера маршруты, параметры контрольных точек, режим их обхода и отправлять на сервер данные по обходам и значениям, зафиксированным на контрольных точках
Как и в веб-клиенте в мобильном приложении имеются компоненты для страниц (экранов), однако вёрстка на Flex и из Android компонентов UI. Формы сделаны через redux-form и react-native-clean-form, но это довольно кривое решение, годное разве что для прототипа.
Также есть навигация между экранами, но вместо react-router веб клиента, здесь используется react-native-router-flux).
Есть запросы к БД, но они выполняются не через GraphQL к серверу, а прямо через вызовы actions, в которых через react-native-sqlite-storage происходит обращение к локальной SQLite БД (учитывая, что запросы асинхронные — используется redux-thunk)
GraphQL в мобильном приложении используется только для синхронизации данных с сервером и отсылки на сервер сообщений с id NFC метки и с ответами на отладочные запросы к БД (см. далее). Основная работа с приложением происходит в offline.
Выглядит компонент для экрана примерно так:
containers/CheckpointContainer.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import CheckpointPage from '../components/CheckpointPage';
import { saveCheckpoint } from '../actions/CheckpointsActions';
import { Actions } from 'react-native-router-flux';
import { ToastAndroid } from 'react-native';
class CheckpointContainer extends Component {
handleSubmit = (data, dispatch) => {
return new Promise((resolve) => {
setTimeout(() => {
this.props.saveCheckpoint(this.props.checkpoint_id,this.props.checkup_id,data.check_note,data.params).then((result)=>{
ToastAndroid.showWithGravity('Данные сохранены', ToastAndroid.SHORT, ToastAndroid.BOTTOM);
return true;
}).catch((error)=>{
ToastAndroid.showWithGravity('Не удалось сохранить данные', ToastAndroid.SHORT, ToastAndroid.BOTTOM);
})
resolve();
}, 100)
})
}
render()
{
return <CheckpointPage {...this.props} onSubmit={this.handleSubmit.bind(this)} />
}
}//class
const mapStateToProps = (state) => {
return {
stateValues: state.checkpointData,
};
};
const mapDispatchToProps = (dispatch) => {
return bindActionCreators ({
saveCheckpoint: (checkpoint_id, checkup_id, note,formData) => saveCheckpoint(checkpoint_id, checkup_id, note, formData),
}, dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(CheckpointContainer);
components/CheckpointPage.js
import React, { Component } from 'react'
import styles from '../styles';
import { reduxForm } from 'redux-form/immutable'
import {
ActionsContainer,
Button,
FieldsContainer,
Fieldset,
Form,
} from 'react-native-clean-form'
import {
Input,
} from 'react-native-clean-form/redux-form-immutable'
import {
View,
Text,
StyleSheet,
} from 'react-native'
class CheckpointPage extends Component {
render() {
const { handleSubmit, submitting, onSubmit } = this.props
return (
<View style={{marginTop: 80, flex:1, flexDirection: 'column', }}>
<Form>
<FieldsContainer>
<Fieldset label="ПАРАМЕТРЫ:" last>
{
this.props.stateValues.params.map((param, i) => {
[...]
})
}
</Fieldset>
<Fieldset label="ЗАМЕЧАНИЯ:" last>
<Input name="check_note" label="Замечания:" placeholder="заметки, комментарии..." multiline={true} numberOfLines={2} inlineLabel={false} />
</Fieldset>
</FieldsContainer>
<ActionsContainer>
<Button icon="md-checkmark" iconPlacement="right" onPress={handleSubmit(onSubmit)} submitting={submitting}>Сохранить</Button>
</ActionsContainer>
</Form>
</View>
)
}
}
export default reduxForm({
form: 'Form',
enableReinitialize: true,
keepDirtyOnReinitialize: true,
})(CheckpointPage)
actions/CheckpointsActions.js
import * as types from '../actions/ActionTypes';
var uuid = require('react-native-uuid');
[...]
export function saveCheckpointSuccess(data) {
return {
type: 'SAVE_CHECKPOINT_SUCCESS',
data
};
}
function txUpdateChecks(result, check_id, note)
{
console.log('CheckpointsActions/txUpdateChecks:', check_id, note);
return new Promise(function(resolve, reject) {
db.transaction((tx)=>{
tx.executeSql(`UPDATE checks SET note = '${note}', dt_mod = '${nowSQL()}' WHERE checks.id = '${check_id}'`).then(([tx,results]) =>{
result = results.rowsAffected;
resolve(result);
}).catch((error) => {
reject(error);
});//catch executeSql
});//db.transaction
})//return
}//txUpdateChecks()
[...]
export function saveCheckpoint(checkpoint_id, checkup_id, note, values)
{
return (dispatch) => {
let result;
return txSelectChecks(checkup_id,checkpoint_id).then((check_id) => {
if (!check_id)
{
txInsertChecks(result,checkpoint_id,checkup_id,note).then((check_id) => txInsertCheckValues(result,check_id,values))
.then((result) => {
dispatch(saveCheckpointSuccess(result));
}).catch(error => {
dispatch(dbFailed({method:'saveCheckpoint !check_id',error}));
});
}//if
else
{
txUpdateChecks(result,check_id,note).then((result) => txUpdateCheckValues(result,check_id,values))
.then((result) => {
dispatch(saveCheckpointSuccess(result));
}).catch(error => {
dispatch(dbFailed({method:'txUpdateChecks/tx',error}));
});
}//else
}).catch(error => {
dispatch(dbFailed({method:'txSelectChecks/tx',error}));
});
}//return
}//saveCheckpoint
Переходы между экранами выполняются вызовами вида:
Actions.routesPage({shop_id:'6f6853d0-a642-11e7-83e7-792a5b00d12c_shop'});
Чтение NFC меток
Как уже было упомянуто, для идентификации контрольных точек предполагалось снабжать их NFC метками. Несмотря на то, что существуют различные типы таких меток, в данном случае дело упрощается тем, что для идентификации достаточно лишь читать их id, который (взависимости от типа метки) выглядит, например, как «F1:E8:A5:9E» или «04:2C:9F:12:85:20:83». Для целей тестирования при этом годятся любые карты — карты для проезда в метро, банковские и т.п.
Как это ни странно, среди нативных модулей React-Native для Android не оказалось ничего, связанного с NFC. Из сторонних тогда нашёлся только один модуль, который мне (и, похоже, другим людям тоже) заставить работать не удалось.
Нативные модули состоят из двух частей — одна на Javascript, вторая на Java. Я не пишу на Java но, посмотрев примеры и полистав документацию, выжал из себя пару десятков строк, которые читали NFC id карты и дёргали js обработчик. Не скрою — когда мой модуль заработал, я довольно сильно удивился.
Проблема со сторонними нативными модулями ещё и в том, что для их установки (по крайней мере, в некоторых случаях — мне попадались такие модули) необходимо руками лезть в несколько файлов типа android/app/src/main/java/[...]/MainApplication.java, android/settings.gradle, android/app/build.gradle. Потом это создаёт всякие неприятные ситуации с последующей установкой других пакетов. Не знаю, является ли это недоработкой таких как я авторов, или проблемой React Native. Так или иначе, модуль заработал. Далее, необходимо было прописать в AndroidManifest.xml:
[...]
<uses-permission android:name="android.permission.NFC" />
[...]
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED"/>
</intent-filter>
<meta-data android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
[...]
А в nfc_tech_filter.xml, соответственно, перечислить все возможные типы NFC меток:
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<tech-list>
<tech>android.nfc.tech.IsoDep</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NfcA</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NfcB</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NfcF</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NfcV</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.Ndef</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NdefFormatable</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.MifareClassic</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.MifareUltralight</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NfcBarcode</tech>
</tech-list>
</resources>
В самом приложении всё уже получается просто:
const nfcListener = DeviceEventEmitter.addListener('NFCCardID', data =>
{
console.log('NFC id', data.id)
[...]
})
В эмуляторе, для имитации прикладывания смартфона к NFC метке, я не придумал ничего умнее, чем аналогичный обработчик для кнопки «Back». К слову — странно, что в Android эмуляторе не предусмотрено хотя бы симуляции NFC. Ведь в смартфонах NFC встречается уже далеко не первый год.
При создании контрольных точек (в веб приложении) необходимо поместить в базу их NFC id. Чтобы не устраивать сложных плясок с внешними USB NFC reader'ами или пересылкой id из сторонних приложений на смартфоне (типа такого), было реализовано чтение NFC id самим мобильным приложением с передачей его в веб клиент, через сервер.
Для этого использован механизм GraphQL subscriptions (подписки).
Мобильное приложение переводится в режим «Привязка точек» в котором, при прикладывании NFC метки и нажатии кнопки «Отправить» вызывается
метод sendTag({variables: { tag: {shop_id: 1, tag_id: data.tagId} } })
этот вызов запускает на сервере метод sendTag, внутри которого вызывается
pubsub.publish('tagSent', { tagSent: newMessage, shop_id: '1'});
Это, в конечном итоге, приводит к вызову в веб клиенте метода updateQuery, в который передаётся tag_id, после чего он оказывается в нужном поле веб-формы.
Ниже фрагменты исходников всех составных частей приложения, которые реализуют эту функциональность:
Мобильное приложение:
index.android.js
[...]
import { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-ws';
wsClient = new SubscriptionClient(`ws://10.0.2.2:3002/subscriptions`, {
reconnect: true,
timeout: 10000,
} )
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
networkInterface,
wsClient,
);
function dataIdFromObject (result) {
if (result.__typename) {
if (result.id !== undefined) {
return `${result.__typename}:${result.id}`;
}
}
return null;
}
const client = new ApolloClient({
networkInterface: networkInterfaceWithSubscriptions,
customResolvers: {
Query: {
channel: (_, args) => {
return toIdValue(dataIdFromObject({ __typename: 'Channel', id: 1 }))
},
},
},
dataIdFromObject,
});
[...]
components/CheckpointAttachPage.js
[...]
<Button title='Отправить номер метки' icon={{name:'tap-and-play'}} buttonStyle={styles.button} onPress={ () => { this.props.sendTag({variables: { tag: {shop_id : 1, tag_id : data.tagId} } }).then(res => {
ToastAndroid.showWithGravity(`Номер метки отправлен`, ToastAndroid.SHORT, ToastAndroid.BOTTOM);
} ).catch(error =>{
ToastAndroid.showWithGravity(`Ошибка при отправке (сеть?)`, ToastAndroid.SHORT, ToastAndroid.BOTTOM);
}) ; } } />
[...]
containers/CheckpointAttachContainer.js
[...]
const gqlSendTag = gql`
mutation sendTag($tag: TagInput!) {
sendTag(tag: $tag) {
tag_id
shop_id
title
}
}
`;
const gqlSendTagProps = {
name: 'sendTag',
options: {
},
};
[...]
Сервер:
server.js
import express from 'express';
import { createServer } from 'http';
const server = express();
[...]
const ws = createServer(server);
server.use(PATH.GIQL, graphiqlExpress({
endpointURL: PATH.GQL,
subscriptionsEndpoint: `ws://${GQLSERVERLOCAL.HOST}:${GQLSERVERLOCAL.PORT}/subscriptions`
}));
ws.listen(GQLSERVERLOCAL.PORT, () => {
new SubscriptionServer({
execute,
subscribe,
schema
}, {
server: ws,
path: PATH.SUBS,
});
});
[...]
schema.js
type Mutation {
[...]
sendTag(tag: TagInput!): Tag
}
[...]
input TagInput{
shop_id: String
tag_id: String
title: String
}
type Tag {
shop_id: String
tag_id: String
title: String
}
type Subscription {
tagSent(shop_id: String): Tag
}
[...]
resolvers.js
import { PubSub } from 'graphql-subscriptions';
import { withFilter } from 'graphql-subscriptions';
const pubsub = new PubSub();
[...]
export const resolvers = {
Mutation: {
sendTag: (root, { tag }) => {
const newMessage = { title: tag.title, shop_id: tag.shop_id, tag_id: tag.tag_id };
pubsub.publish('tagSent', { tagSent: newMessage, shop_id: '1'});
return newMessage;
},
[...]
},
Subscription: {
tagSent: {
subscribe: withFilter(() => pubsub.asyncIterator('tagSent'), (payload, variables) => {
return payload.shop_id === variables.shop_id;
}),
},
[...]
},
}
Веб клиент:
index.js
[...]
import { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-ws';
wsClient = new SubscriptionClient(`ws://localhost:3002/subscriptions`, {
reconnect: true,
})
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
networkInterface,
wsClient,
);
function dataIdFromObject (result) {
if (result.__typename) {
if (result.id !== undefined) {
return `${result.__typename}:${result.id}`;
}
}
return null;
}
const client = new ApolloClient({
networkInterface: networkInterfaceWithSubscriptions,
customResolvers: {
Query: {
channel: (_, args) => {
return toIdValue(dataIdFromObject({ __typename: 'Channel', id: 1 }))
},
},
},
dataIdFromObject,
});
[...]
components/AddCheckpointPage.js
import { Field } from 'redux-form';
[...]
<Field className="form-control" id="tag_id" name="tag_id" type="text" component="input" placeholder="В приложении на телефоне выберите 'привязка метки'"/>
[...]
containers/AddCheckpointContainer.js
[...]
import { change as changeFieldValue } from 'redux-form';
const tagSubscription = gql`
subscription tagSent($shop_id: String) {
tagSent(shop_id: $shop_id) {
tag_id
shop_id
title
},
}
`
class AddCheckpointContainer extends React.Component {
componentWillMount() {
this.props.data.subscribeToMore({
document: tagSubscription,
variables: {
shop_id: 1,
},
onError: (err) => console.error('subscribeToMore ERROR:',err),
updateQuery: (prev, {subscriptionData}) => {
console.log('updateQuery:',subscriptionData)
this.props.changeFieldValue("addCheckpointForm", "tag_id", subscriptionData.data.tagSent.tag_id);
}
});
}//componentWillMount()
[...]
}
В завершении разбора — небольшое видео, демонстрирующее работу мобильного приложения и веб клиента:
Средства разработки и отладка
Писалось и отлаживалось всё под Windows 7, но для демонстрации заказчику переносилось на linux сервер. Что касается среды разработки — я любитель Sublime. Правда, должен заметить, что с подсветкой синтаксиса gql запросов внутри js кода регулярно случаются глюки. Уж больно там это всё нетрадиционно сочетается. Пару раз возникала мысль попробовать VSCode (но пока держусь).
Установка nodejs под Win7 никаких особых проблем не вызвала, а вот с установкой и настройкой Android SDK так, чтобы всё потом вместе зашевелилось — пришлось хорошо повозиться.
Что касается средств отладки, то для веб клиента это обычный Chrome Dev tools с расширениями Apollo Client Developer Tools и Redux DevTools. Для мобильного приложения — React Native Debugger (практически тоже самое, только реализованное отдельно на Electron).
Отмечу, что для локальной отладки с эмулятором, вместо localhost нужно использовать адрес 10.0.2.2
Кроме того, для удобства отладки в самих клиентах и сервере я реализовал возможность посылать SQL запросы к SQLite базе в смартфоне — как непосредственно со смартфона (из меню «Отладка»), так и удалённо — из веб приложения.
Во втором случае это осуществляется через механизм GraphQL подписки, через веб-сокеты. Веб приложение вызывает sendDebugRequest() на сервере, который формирует сообщение newDebugRequestCreated, на которое подписано мобильное приложение. В мобильном приложении при получении этого сообщения выполняется execSql(). Результат запроса отправляется на сервер вызовом sendDebugResponse(), который в свою очередь формирует сообщение newDebugResponseCreated, на которое подписано веб приложение. Фактически, это подобие чата, просто из мобильного приложения «отвечает» не человек, а БД.
Для просмотра и редактирования файлов SQLite под Win7 очень хорошо мне зашёл (после перебора разных вариантов) — SQLiteStudio.
Полезная мелочь — чтобы на сервере console.log() красиво и полностью выводил объекты, можно пользоваться util.inspect()
const util = require('util')
util.inspect.defaultOptions.showHidden = false
util.inspect.defaultOptions.depth = null
util.inspect.defaultOptions.maxArrayLength = 1000
util.inspect.defaultOptions.colors = true
[...]
console.log("data: ", util.inspect(data))
Также в связи с сервером — всё время забываю, что если в переменной промис то, чтобы увидеть результат в логе сервера не в виде "[Promise]", обязательно нужен then.
variable.then(function(result) { console.log('Эврика!',result) })
Авторизация/аутентификация
При выполнении регистрации, веб-клиент вызывает серверный метод signUp, где из введённого пароля при помощи bcrypt.hash генерируется хэш, который вместе с email (логином) помещается в БД.
При логине через веб-клиент вызывает серверный метод signIn, в котором проверяется наличие пользователя с данным email и корректность пароля (через bcrypt.compare). После этого в signIn вызовом jwt.sign генерирует токен, который возвращается веб-клиенту.
Веб-клиент сохраняет токен в localStorage и далее достаёт его оттуда и предъявляет серверу в http заголовке при каждом GraphQL запросе (для чего в network.use applyMiddleware указывается req.options.headers.authorization = `Bearer ${token}`)
Сервер, получая GraphQL запросы, в server.js каждый раз берёт полученный с ними токен из http заголовка Authorization, проверяет и дешифрует его при помощи express-jwt, получая user.id, по которому из БД извлекаются необходимые данные о правах пользователя, затем передаваемые в context, доступный внутри всех вызываемых клиентом серверных методов (в connectors.js). При этом важно, что jwt() вызывается с параметром «credentialsRequired: false», что позволяет игнорировать ошибки аутентификации и обрабатывать их самим (это необходимо для работоспособности компонентов SignIn и SignUp в незалогиненном состоянии).
Мобильное приложение, в отличии от веб клиента, должно работать без доступа к сети. Поэтому, при синхронизации БД с сервера, среди прочего передаются данные (email, хэш пароля) пользователя. При логине в мобильное приложение запроса к серверу не осуществляется — проверяется существование пользователя и корректность пароля либо nfc_id (взависимости от того, вводится логин вручную или прикладывается NFC карта) по копии в локальной БД, вызовом bcrypt.compare для React Native.
При соединении с сервером предъявляется идентификатор устройства и другие данные, на основании которых сервер разрешает доступ.
Проблемы
Как уже было упомянуто в начале статьи, это моё первое знакомство с перечисленными технологиями. И вот каковы мои впечатления:
- Зоопарк пакетов, связанных сложными зависимостями, причём каждый тянет за собой десяток других. Обновление одних нередко всё ломает — причём, иногда это происходит без каких-либо сообщений об ошибках и обнаружить, в чём проблема — весьма сложно. Особенно неприятны ситуации, когда какой-то пакет необходимо обновить, т.к. в нём исправлен критичный баг, но обновлять его нельзя, потому что тогда перестанет работать третий. Другая ситуация — когда обновить необходимо, но в пакете изменилась какая-то логика и требуется переписать, скажем, 10-20% моего приложения. Получается дилемма — или ты сидишь на старых версиях тщательно подобранных друг к другу пакетов и боишься чихнуть, или постоянно всё обновляешь и занят переделыванием своего кода под всё новые и новые изменения в них.
Чисто эмоциональный аспект: ситуация, когда node_modules/ для проекта занимает порядка 200mb и содержит десятки тысяч (!) файлов — с непривычки шокирует. Да, понятно, что в окончательный код попадает не так уж много (десятки файлов и единицы мегабайт), но тем не менее. - Настройка webpack/babel — очень нетривиальное занятие. Я так понимаю, люди не так часто руками пишут все конфиги с нуля — наверное обычно берут какой-нибудь готовый «суповой набор», типа create-react-app и create-react-native-app. По этому пути пошёл и я, но в итоге всё равно возникли ситуации, когда пришлось лезть в конфиги и разбираться. Кроме того, есть вещи, которые такие наборы принудительно навязывают (к примеру, неотключаемый eslint или внезапный service-worker.js, используемый для кэширования)
- При сборке (в моём случае — под Win7) в разных ситуациях регулярно возникают сообщения об ошибках, которые на самом деле не указывают на проблемы с приложением и зачастую эти сообщения лечатся простым перезапуском. Причём, вчера ошибки не возникали, сегодня появились, завтра могут исчезнуть опять и потом вновь появиться. К примеру, в данный момент при запуске react-native run-android --variant=release у меня последовательно появляются несколько разных ошибок (связанных, по всей видимости, с невозможностью получить доступ к файлам в assets/) и проблема решается трёх-пятикратным повторением этой операции (если не помогает, ещё приходится дополнительно почистить \android\.gradle ).
Когда начинаешь гуглить обо всём этом, выясняется, что через такое проходят сотни человек, причём все решают это разными (часто — магическими) способами, которые у каждого свои и редко помогают другим страдальцам. Постепенно к этому привыкаешь и уже интуитивно понимаешь, что конкретно надо попробовать предпринять в первую очередь.
Стоит также упомянуть о плясках с бубном для получения подписанного для релиза apk — с ползанием по конфигам gradle и выяснением что, оказывается, где-то там надо было буквы строчные вместо прописных, лишний перевод строки, еще один конфиг не в том директории где он был (внезапно!) и т.п. ньюансы. Это тоже одна из популярных проблем, судя по огромному количеству вопросов, советов и магических решений.
Главным образом, наверное, всё перечисленное объясняется относительной новизной и сыростью технологии, но также и множеством различных компонентов и скриптов разных авторов, которые постоянно их развивают и потерю совместимости правят уже по факту (при появлении достаточного числа пострадавших), да и то не всегда.
Что бы я сделал иначе, если бы начинал эту работу сейчас?
- Взял бы более свежую комбинацию пакетов:
- React Router 4 (он сильно изменился по сравнению с 3)
- Новую четвертую версию react-native-router-flux (аналогично)
- Вместо своего NFC велосипеда посмотрел бы, что появилось в этой области. Например это.
- Обновил версию Apollo GraphQL и попробовал бы весь обмен в GraphQL сделать через сокеты (сейчас через сокеты только сообщения).
- Вместо react-bootstrap-table использовал бы react-bootstrap-table2 или что-то другое (может быть react-table).
- Возможно, отказался бы от Bootstrap. С другой стороны, Material UI выглядит на десктопе не очень адекватно в плане использования пространства экрана. По существу, итоговый выбор еще зависит и от остальных используемых пакетов.
- Для работы с БД возможно стоит использовать прокладку в виде Sequilize. Кроме того, по-прежнему не уверен, следовало ли в мобильном приложении возиться с SQLite и не стоило ли всё, что нужно (данные с сервера, результаты обходов) держать вместо этого в redux store.
Вообще, понимание, что нужно хранить в store, для чего достаточно локального state, а что помещать в БД — это всё, конечно, приобретается с опытом. - Использовать в React Native другой drawer (тот, что был доступен на момент начала работ был не целиком нативным и из-за этого заметно тормозил, а при включенной отладке — вообще еле двигался). По идее, сейчас этот вопрос уже решён в react-navigation, которая используется в новом react-native-router-flux.
- Формы, на мой взгляд — настоящая проблема. Я использовал redux-form, но даже в веб клиенте простые вещи решаются достаточно сложно и запутанно. А с React Native она вообще не очень работает. Несколько раз я думал избавиться от redux form и сделать всё самому и в react и в react native. Но лень каждый раз побеждала.
В заключение, порекомендую две статьи, которые однозначно стоит прочесть по теме (таких статей, увы, не так уж много, особенно в связи с быстрым устареванием материала): Создание «клона WhatsApp» (на React-Native, NodeJS, GraphQL) и лучшая, на мой взгляд, статья про GraphQL: Dive intro GraphQL.
Комментарии (14)
alex_kag
27.02.2018 13:36Замечательная система.
В дополнение к написанному, хотелось бы знать, как идет обмен между мобильным клиентом и сервером — wifi или gprs? (очень бы хотелось увидеть структуру сети… )
А тем, кто кричит, что проще повтыкать датчиков для мониторинга и все — одно не отменяет другого. Обходы в первую очередь делаются для визуального контроля работы оборудования. И съем данных это уже дело десятое. Хотя в некоторых местах и приходится иногда считывать аналоговые датчики, но это скорее исключение, чем правило. НО если сменный персонал не будет считывать данные, то скорее всего, что обход будет липовым. Поэтому такая система — очень даже замечательная.frog Автор
27.02.2018 13:51WiFi. Причём не везде (потому что во время обхода легко могут быть места, где связи никогда не будет — из-за расположения объекта или из-за помех от оборудования), а в конкретных местах — грубо говоря, где сотрудник ставит смартфон на зарядку.
aquamakc
27.02.2018 13:58Если всё делать правильно, то оборудование желательно оснастить датчиками для дистанционного мониторинга и оперативного обнаружения отклонений в работе, но при этом никто не отменял техническое обслуживание руками, которое может может быть нескольких видов. Как частность, есть ГОСТ 60079-17 «Проверка и техническое обслуживание электроустановок во взрывоопасных зонах», который предусматривает в т.ч. несколько видов проверок: визуальная, непосредственная, детальная. В зависимости от объёма работ при проведении проверок. От «просто посмотреть», через «подкрутить, потыкать» до «провести диагностику спец-средствами».
Каждая проверка осуществляется с разной периодичностью, например: визуальная — раз в неделю, непосредственная — раз в квартал или по результатам визуальной, детальная — раз в год или по результатам предыдущих.
Это я к чему? Если у нас сложное технологическое оборудование, то нет смысла спорить, как его лучше мониторить — дистанционно или «ногами». Идеальный вариант — и дистанционно в режиме «онлайн» и живым человеком с необходимым периодом.
knutov
28.02.2018 12:19А чем рисовали оранжевую схемку?
frog Автор
28.02.2018 14:03VUE — Visual Understanding Environment. Хорошая штука — очень нравится использовать для подобных целей.
lingvo
ИМХО такая формулировка означает "был облом изучать современные подходы удаленному мониторингу". В вашем решении используется человек, которому во первых нужно платить зарплату, а во вторых который может ошибиться и занести неправильные данные, в третьих он может просто обмануть систему, отодрав NFC метки и положив их себе в кабинет. Ну и в четвертых параметры мониторинга и следовательно ваша карта обходов зависят от того, как часто надо контролировать тот или иной параметр. Т.е. у вас явно есть точки, которые надо посещать часто и авария на которых очень критична. Значит там нужна какая-то автоматическая сигнализация о выходе параметра за пределы, без того, чтобы его кто-то считал при следующем обходе.
В итоге деньги на такую полуавтоматизацию — это деньги, выброшенные на ветер, а система удаленного мониторинга с учетом последних технологий сегодня и стоит недорого, и проста в проектировании и развертывании и решает гораздо больше проблем.
frog Автор
Ну, вопрос несколько не ко мне но, будучи в теме, я могу предположить, почему было выбрано именно такое решение. Невозможно обновить оборудование сразу на всех объектах и во всех цехах на такое, с которого можно получать данные удалённо — просто не хватит никаких денег. Поэтому задача решается поэтапно — сначала люди с бумажками бегали, потом с телефонами, потом оборудование заменят.
Как выглядит и работает то, что вы называете системой удалённого мониторинга? Ну т.е. вот допустим в цеху стоит некий агрегат с кучей всяких красивых манометров в стиле стим-панк. Местами там из него допустим пар может переть или жидкость какая-нибудь течь. И все эти манометры и факты наличия утечек пара и жидкости нужно регулярно контролировать. Как это решает система о которой вы говорите?
Насчёт частоты контроля параметров — да, для разных объектов и цехов периодичность регламентирована, соответственно приложение должно напоминать сотруднику что «пора». В прототипе этого просто нет (как и ещё ряда вещей, которые должны быть в окончательном варианте).
Что касается обмана системы, отдирания меток и т.д. — это нарушение сотрудником должностных инструкций (в лучшем случае) и решается соответствующим образом. Да, конечно, было бы лучше, если бы это было невозможно технически, но см. выше.
lingvo
Для начала это зависит от того, какие задачи ставятся перед такой системой и насколько критично оборудование, которое она мониторит. При этом надо учитывать стоимость простоя и ремонта. Тогда вопрос "не хватит никаких денег" встает по другому "а сколько денег вы теряете сегодня в результате незапланированных остановок и ремонтов?" Знаю, что звучит, как реклама, но в реальности если взять и один раз посчитать, сразу меняется взгляд на проблему. Правда, в большинстве случаев в результате принимается решение не ставить мониторинг, а тупо выбрасывается старое оборудование и берется новое в лизинг по указанным выше причинам.
А дальше, если принято решение делать мониторинг, то для начала стоит проанализировать, какие параметры наиболее критичны и поставить мониторинг на них. Также хорошо работает такой принцип — при наличии мониторинга нескольких параметров остальные можно рассчитать по ним, зная модель системы. Или, если эти параметры тоже измеряемы, их перепроверить. Пример — трехфазный генератор — для мониторинга достаточно двух датчиков тока, так как сумма токов равна нулю и третий ток можно рассчитать. Либо поставить три датчика и по сумме контролировать не вышел ли один из них из строя.
frog Автор
Так всё же, как в таких системах решается проблема обнаружения всяких выбросов пара, течи жидкостей, образования конденсата?
lingvo
Как? Датчики, конечно. Ваш же человек считывает параметры с каких-то измерительных приборов? Так вот вместо них или параллельно им вкручиваются или ставятся измерительные преобразователи давления, расхода, температуры, влажности и т.д и подключаются проводами к системам збора данных, которые уже отдают нормированные измерения в сеть с необходимой периодичностью, добавляя метку времени и другую служебную информацию. Эти же системы также диагностируют состояние самого датчика и его подключение и в случае чего выставляют флаги неисправности.
lingvo
Конденсат определяется легко — влажность + температура + давление = имеем точку росы и вероятность образования конденсата.
С утечками пара и жидкости нужно, конечно, быть изобретательнее. Если жидкость токопроводящая — то тут проще — электроды и можно контролировать. С непроводящими сложнее. По пару я не в курсе, но есть генеральный метод — измерять расход и сопоставлять его с ожидаемым. Например периодически отключать все оборудование на минуту и смотреть за расходом пара. Если он есть, значит где-то утечка. Насколько я знаю, это не так сложно организовать, оборудование даже не надо отключать, если вы четко знаете, когда оно останавливается по технологическим причинам.
frog Автор
Представьте себе агрегат размером, скажем, 3x3 метра. Из любого места которого может что-то теоретически начать течь. При этом пол вокруг него не сплошной (местами решётка на нижние уровни). Т.е. на пол датчиков наставить не получится.
Обнаружение течи по измерению расхода — ну, если там эта жидкость в больших количествах циркулирует, сомневаюсь что удастся зафиксировать таким образом небольшую течь. В то время, как она может сигнализировать о наличии, скажем, трещины в корпусе.
Периодически отключать оборудование на минуту невозможно — оно должно работать постоянно (за исключением периодов отключение для проверок и ремонта, которые есть серьёзное событие для объекта и занимает скорее дни, чем минуты).
Короче говоря — то, что вы предлагаете, оно наверняка было бы возможно, но лишь при некоторых удачных условиях.
lingvo
Может все-таки скажете, что это за агрегат конкретно? А то по вашему описанию — пар, жидкости, стимпанк, у меня воображение рисует припаркованный паровоз.
Его, конечно, удаленно мониторить вряд-ли имеет смысл.
aquamakc
Как показывает практика моего личного опыта, даже после полной автоматизации мониторинга 99% организаций продолжают дополнительно мониторить оборудование «ножками». Ну или совсем уж клинический случай, когда задействуется 2 параллельных системы дистанционного мониторинга от разных производителей + «ножками».