Розробка мобільних додатків для розумного будівлі (BMS)
BMS-проекти починаються однаково: замовник показує схему будівлі з контролерами Siemens Desigo CC, Schneider Electric EcoStruxure або Johnson Controls Metasys та говорить «хочемо видіти все це у телефоні». За цим «все це» скриваються десятки протоколів, polling-цикли від 1 секунди до 15 хвилин, історична база на роки назад та вимога працювати навіть коли основний сервер BMS перезагружається.
Протоколи та шлюзи
Промислові BMS говорять на BACnet/IP, Modbus TCP/RTU, KNX/IP та LonWorks. Напрямку з них не ходять — між контролерами та REST/WebSocket API стоїть шлюз або middleware.
Типовий стек інтеграції:
| Рівень | Технологія |
|---|---|
| Контролери | BACnet/IP, Modbus TCP, KNX |
| Шлюз | Node-RED, Niagara Framework 4, кастомний Python/Go сервіс |
| Transport | MQTT over TLS, REST, WebSocket |
| Мобільний клієнт | Flutter / Swift / Kotlin |
Niagara Framework 4 (Tridium) — де-факто стандарт для великих об'єктів. Він умеє нормалізувати BACnet-об'єкти в єдиний REST API (/haystack/api/read?filter=bacnet) та віддавати WebSocket-стрім змін. Робота з Haystack API через Dart:
class HaystackClient {
final Dio _dio;
final String _baseUrl;
HaystackClient(this._baseUrl, String username, String password) :
_dio = Dio(BaseOptions(
baseUrl: _baseUrl,
headers: {
'Authorization': 'Basic ${base64Encode(utf8.encode('$username:$password'))}',
'Accept': 'application/json',
},
));
Future<List<HaystackRow>> read(String filter) async {
final response = await _dio.get('/haystack/api/read',
queryParameters: {'filter': filter});
final grid = HaystackGrid.fromJson(response.data);
return grid.rows;
}
Future<Map<String, dynamic>> readPoint(String pointId) async {
final response = await _dio.get('/haystack/api/hisRead',
queryParameters: {
'id': '@$pointId',
'range': 'today',
});
return response.data;
}
}
Для об'єктів з MQTT-шлюзом (Node-RED конвертує BACnet → MQTT JSON) використовуйте mqtt_client у Flutter. Топіки організуйте по ієрархії будівлі: building/{buildingId}/floor/{floor}/zone/{zone}/{parameter}.
Архітектура даних реального часу
Найскладніший момент у BMS-додатку — не підключення, а управління потоком даних. Температура у 200 зонах оновлюється кожні 30 секунд, освітлення — за подією, енергоспоживання — кожну хвилину. Все це неможна переписувати при кожному перерисуванні UI.
Рішення — централізований DataHub на рівні додатка:
class BmsDataHub {
final MqttClient _mqtt;
final _streams = <String, BehaviorSubject<BmsPoint>>{};
Stream<BmsPoint> watchPoint(String pointId) {
if (!_streams.containsKey(pointId)) {
_streams[pointId] = BehaviorSubject();
_mqtt.subscribe('building/+/+/+/$pointId', MqttQos.atLeastOnce);
}
return _streams[pointId]!.stream;
}
void _onMessage(List<MqttReceivedMessage<MqttMessage>> events) {
for (final event in events) {
final topic = event.topic;
final payload = MqttPublishPayload.bytesToStringAsString(
(event.payload as MqttPublishMessage).payload.message);
final point = BmsPoint.fromJson(jsonDecode(payload));
_streams[point.id]?.add(point);
}
}
}
BehaviorSubject з пакета rxdart зберігає останнє значення — віджет, підписаний після приходу даних, одразу отримує актуальний стан без чекання на наступний цикл polling.
Інтерактивний план етажу
Замовники завжди хочуть план будівлі з живими даними. Конвертуйте DXF або SVG-план у SVG (через ODA File Converter для DXF), рендеріть через flutter_svg + InteractiveViewer. Точки датчиків — overlay поверх SVG з позиціонуванням за нормалізованими координатами:
class FloorPlanWidget extends StatelessWidget {
final FloorPlan plan;
final Map<String, BmsPoint> liveData;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
return Stack(children: [
SvgPicture.asset('assets/floors/${plan.id}.svg',
width: constraints.maxWidth),
...plan.sensors.map((sensor) => Positioned(
left: sensor.x * constraints.maxWidth,
top: sensor.y * constraints.maxHeight,
child: SensorMarker(
point: liveData[sensor.pointId],
type: sensor.type,
),
)),
]);
});
}
}
Маркери змінюють колір за порогами: зелений (норма), жовтий (попередження), червоний (аварія). Пороги беруть з BMS-конфігурації, не хардкодять.
Управління: запис значень в BACnet-точки
Читати простіше, ніж писати. Для командування BACnet-точками (setpoint температури, включення/выключення освітлення) через REST-шлюз:
Future<void> writePoint(String pointId, dynamic value) async {
// Оптимістичне оновлення UI
_hub.updateLocally(pointId, value);
try {
await _api.put('/haystack/api/pointWrite', data: {
'id': '@$pointId',
'level': 8, // приоритет записи BACnet (1-16, нижче = вище приоритет)
'val': value,
'who': _authService.currentUser,
'duration': 'PT0S', // permanent
});
} on DioException catch (e) {
// Откат при ошибке
_hub.revertLocally(pointId);
rethrow;
}
}
BACnet Priority Array — деталь, яку ігнорують і потім не можуть зрозуміти, чому уставка температури не змінюється: контролер приймає команди, але вони перебиваються більш високим приоритетом з BMS-розписання (рівні 2-4). Рівень 8 — стандартний для ручного оператора.
Алерти та журнал событій
Аварійні события з BMS приходять через MQTT або WebSocket. Локальні push-сповіщення генеруємо через flutter_local_notifications, серверні push (коли додаток закритий) — через FCM з високим приоритетом (priority: high, content_available: true).
Журнал событій: SQLite через drift для офлайн-зберігання 30 днів історії, страничне завантаження з API для більш старих записів.
Розграничення прав
У реальних об'єктах різні користувачі видять різні етажі та зони. Права зберігаються на backend, мобільний клієнт запитує список доступних об'єктів при логіні та не будує маршрути до недоступних ресурсів. Спроба написати в заборонену точку → HTTP 403 → локальний откат + сповіщення користувачу.
Розробка мобільного BMS-клієнта з відображенням плану етажу, real-time даними через MQTT/WebSocket та управлінням setpoints: 8–12 тижнів. Повнофункціональна система з підтримкою кількох об'єктів, історичними графіками, алертами та розграниченням прав: 4–6 місяців. Вартість розраховується індивідуально після аналізу протоколів контролерів та вимог до інтеграції.







