48 Commits

Author SHA1 Message Date
fe9e7b5438 draft commit to make null is possible to send 2026-07-06 11:04:30 +05:00
3c94cd92c1 clone data for demo/dev env 2026-07-05 18:54:33 +05:00
5b97022116 less logging 2026-07-05 16:46:53 +05:00
7c67b9e34a data/edge5/modbus/v3 2026-07-05 16:39:15 +05:00
3841faa810 assets 2026-07-05 16:09:32 +05:00
5d18bb7965 finalize video streaming issues 2026-07-05 13:23:48 +05:00
37539469de no delay broker option check" 2026-07-05 12:25:11 +05:00
45a31c7210 http ingest port 2026-07-04 21:51:21 +05:00
03d2710fdb http ingest port 2026-07-04 21:45:05 +05:00
52beffe377 http ingest 2026-07-04 21:38:47 +05:00
cd599a177b ws-pipe-relay 2026-07-04 21:03:50 +05:00
a829a9df3d video optimization/ 2026-07-04 18:57:41 +05:00
eb001771b5 WebSocketServer.clientsByCamera 2026-07-04 18:09:16 +05:00
e0437edf18 data/edge5/video/v2/+ 2026-07-04 17:26:30 +05:00
f61fd87bac fit video 2026-07-03 17:38:23 +05:00
80772c8751 custom domain name 2026-07-03 17:30:05 +05:00
3eb9680ef8 networks comments 2026-07-03 17:16:31 +05:00
495651379a video preview page 2026-07-03 17:07:55 +05:00
11a4f9d03a app/public/index.html 2026-07-03 16:51:55 +05:00
9988281368 exceeded log 2026-07-03 13:34:52 +05:00
60bde31a7b cdab validate numbers 2026-07-03 12:13:54 +05:00
5b53f4ed12 Merge branch 'dev' 2026-07-03 12:06:03 +05:00
337497171a stringify 2026-07-03 12:03:39 +05:00
d65bf84a60 Merge pull request 'добавлена поддержка dev окружения' (#10) from dev into main
Reviewed-on: #10
2026-07-03 06:38:41 +00:00
Первов Артем
64dde77f7b добавлена поддержка dev окружения 2026-07-02 22:39:59 +03:00
d7bfaa678e data/edge5cdab/modbus & logging 2026-07-02 18:39:43 +05:00
f5dd5a7f7f padStart(3, '0') 2026-07-02 15:51:33 +05:00
540a8ba1e9 data/edge5i/modbus 2026-07-02 14:40:49 +05:00
16c1712752 data/edge5/modbus/v1 to demo refactoring 2026-07-01 13:34:28 +05:00
ac4e0d68c3 Merge pull request 'Обновил отправку тегов с массива на построчный формат' (#8) from edge5modbus into main
Reviewed-on: #8
2026-06-22 06:45:44 +00:00
Первов Артем
c8fef3e025 Убрал лишнюю нормализацию 2026-06-22 03:29:36 +03:00
Первов Артем
de4d6eccbe Обновил отправку тегов с массива на построчный формат 2026-06-22 02:48:23 +03:00
adde96d8ea Merge pull request 'Скорректировал порядок сборки в Dockerfile. Устранена проблема - не подтягиваливались devDependencies (tsc) при production сборке.' (#7) from edge5modbus into main
Reviewed-on: #7
2026-06-19 12:46:00 +00:00
Первов Артем
c4c2923f1f Скорректировал порядок сборки в Dockerfile. Устранена проблема - не подтягиваливались devDependencies (tsc) при production сборке. 2026-06-19 11:27:24 +03:00
34f24824e7 Merge pull request 'remove default env values. correct package json with env vars .' (#6) from edge5modbus into main
Reviewed-on: #6
2026-06-19 08:18:07 +00:00
Первов Артем
d00f79f4d3 Убрал копирование public папки из dockerfile. Вернул валидацию на количество тегов, присылаемых с node-red. Вернул чтение .env переменной при локальном запуске. 2026-06-19 11:10:51 +03:00
Первов Артем
9b53f602ce Убрал лишние проверки. Убрал дефолтные значения env переменных. Скорректировал dockerfile 2026-06-19 10:30:13 +03:00
Первов Артем
cbe33500cf remove default env values. correct package json with env vars . 2026-06-19 09:41:16 +03:00
381bb5db31 Merge pull request 'add mapper. add v2 handler. add tag description short and full versions. delete topic[1-5].' (#5) from edge5modbus into main
Reviewed-on: #5
2026-06-19 06:15:28 +00:00
Первов Артем
10dbdb2120 correction after review 2026-06-19 08:17:35 +03:00
Первов Артем
7cf4aacd11 remove redundant parse 2026-06-17 09:40:43 +03:00
Первов Артем
f8a9866339 make mapping.ts little bit simple. add comment to file processing places. 2026-06-15 01:41:30 +03:00
Первов Артем
e450346e58 delete unnecessary verifications. 2026-06-12 02:38:29 +03:00
Первов Артем
07eae3dfb4 add mapper. add v2 handler. add tag description short and full versions. delete topic[1-5]. 2026-06-10 01:44:31 +03:00
ed039af385 Merge pull request 'remove bash code from docker-compose. Add Dockerfile. Remove comments.' (#4) from edge5modbus into main
Reviewed-on: #4
2026-06-09 05:53:08 +00:00
Первов Артем
926a01640b remove bash code from docker-compose. Add Dockerfile. Remove comments. 2026-06-08 21:37:51 +03:00
8820c5b518 Merge pull request 'update CLOUD_INGEST_URL var' (#3) from edge5modbus into main
Reviewed-on: #3
2026-06-05 18:31:41 +00:00
Первов Артем
c549093b32 update CLOUD_INGEST_URL var 2026-06-05 21:31:10 +03:00
42 changed files with 1770 additions and 330 deletions

View File

@@ -1,2 +1,4 @@
node_modules
dist
.env
.env.*

View File

@@ -6,7 +6,11 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY public ./public
COPY src ./src
CMD ["npm", "run", "dev"]
RUN npm run build && npm prune --omit=dev
COPY assets ./assets
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]

96
app/VIDEO.md Normal file
View File

@@ -0,0 +1,96 @@
Финальные требования и настройки у нас сейчас такие.
## Финальный пресет чанкования ffmpeg (RTSP → MPEGTS → stdout)
Мы остановились на сценарии, где ffmpeg:
- тянет RTSP **по TCP** с камер,
- минимизирует внутреннюю буферизацию и время старта,
- не перекодирует видео (копирует H.264 как есть); если возникнут проблемы с нагрузкой на сеть, можно поискать варианты снизить; но на данном этапе важнее "накормить" видеобуфер воспроизведения хоть чем-нибудь;
- выдаёт результат в **stdout (`pipe:1`)**, откуда его забирает mqtt транспорт.
Финальный набор аргументов:
```js
[
'-hide_banner',
'-fflags', 'nobuffer', // не буферировать при probe stream info
'-flags', 'low_delay', // включить low-delay на уровне декодера
'-analyzeduration', '100000', // ограничить анализ потока 100 ms (уменьшить стартовую задержку)
'-probesize', '32768', // зондировать только 32 KB (быстрее старт, меньше буфера)
'-flush_packets', '1', // сразу сбрасывать пакеты, не копить их внутри muxer'а
'-rtsp_transport', 'tcp', // тянуть RTSP по TCP (надёжнее на плохой сети)
'-i', 'rtsp://admin:admin@192.168.0.11:554/live/sub', // sub-поток камеры
'-an', // отключить аудио — нужен только видеофрейм
'-c:v', 'copy', // не перекодировать видео, просто копировать кадры
'-f', 'mpegts', // контейнер MPEG-TS для дальнейшего трансмультиплексирования
'-mpegts_flags', 'resend_headers', // периодически повторять заголовки для надёжного декода
'pipe:1' // выводить поток в stdout (дальше забирает твой транспорт)
]
```
Ключевые моменты:
- `-c:v copy` сохраняет оригинальный кадр и GOP камеры — минимум задержки и вычислительной нагрузки.
- `-fflags nobuffer + -flags low_delay + -flush_packets 1` минимизируют буферизацию и увеличивают «прозрачность» для твоего транспорта.
- `-rtsp_transport tcp` это про **вход** (камера → ffmpeg), а `pipe:1` — про **выход** (ffmpeg → mqtt).
***
## Финальный пресет воспроизведения (mpegts.js + HTML5 video)
На клиенте мы на данном этапе определяемся работать как с «liveвидео с буфером», а не как с чистым realtime:
- формат источника: **MPEGTS/H.264** по WebSocket;
- плеер: `mpegts.js`, режим `type: 'mpegts', isLive: true`;
- буфер: небольшой stash + несколько секунд latency для сглаживания провалов.
Финальные параметры инициализации:
```js
const wsUrl = 'wss://...';
const player = mpegts.createPlayer(
{
type: 'mpegts', // MPEG-TS поток по WS
isLive: true,
url: wsUrl,
hasAudio: false, // видео без аудио
},
{
enableWorker: true,
// Включаем stash, чтобы сглаживать редкий / рваный поток
enableStashBuffer: true,
stashInitialSize: 512, // немного увеличенный stash для накопления
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 10, // держим минимум истории
autoCleanupMinBackwardDuration: 5,
// Разрешаем latency-chasing с «комфортной» задержкой
liveBufferLatencyChasing: true,
liveBufferLatencyMaxLatency: 5.0, // целевая задержка до ~5 секунд
liveBufferLatencyMinRemain: 2.0, // оставляем ~2 секунды буфера после сброса
liveSync: false, // не пытаемся мягко догонять — приоритет стабильности
lazyLoad: false,
deferLoadAfterSourceOpen: false,
}
);
player.attachMediaElement(document.getElementById('v'));
player.load();
player.play();
```
Смысл этих настроек:
- **Stashbuffer включён**: mpegts.js может аккумулировать некоторое количество данных, прежде чем кормить MSE (браузеру), что хорошо при «дырявом» канале.
- **Latencychasing с MaxLatency ≈ 5 s**: браузер допускает несколько секунд отставания, но не позволяет задержке расти бесконечно; при накоплении лишнего буфера он «подпрыгивает» ближе к liveпозиции, сохраняя `MinRemain` ~2 секунды запаса.
- `liveSync: false` — мы не гонимся за минимально возможной задержкой любой ценой, а принимаем небольшой «запас» как разумный компромисс.
При улучшении канала или выполнениии работ по снижению нагрузки на сеть (уменьшения битрейта, fps)
нужно грамотно управлять буфером на стороне клиента (stash + latency).

View File

@@ -0,0 +1,130 @@
{
"edge": "edge5",
"tags": [
"hook_weight_1",
"hookblock_position",
"manifold_pressure_1",
"hydraulic_tong_torque",
"hook_weight_2",
"outlet_flow",
"gas_1_lfl",
"gas_1_h2s",
"gas_2_h2s",
"gas_3_h2s",
"outlet_temperature",
"inlet_flow_1",
"akb_tong_torque",
"rotary_torque",
"rotary_rpm",
"manifold_pressure_2",
"machine_tong_torque",
"inlet_temperature",
"gas_4_h2s",
"density_1",
"density_2",
"density_3",
"density_4",
"density_5",
"density_6",
"level_1",
"level_2",
"level_3",
"level_4",
"level_5",
"level_6",
"volume_1",
"volume_2",
"volume_3",
"volume_4",
"volume_5",
"volume_6",
"total_volume",
"weight_on_bit",
"penetration_rate",
"tripping_speed",
"tool_depth",
"bottom_depth",
"outlet_flow_change",
"stand_feed",
"lowered_stands_count",
"inlet_flow_2",
"slips_position",
"gas_2_lfl",
"level_7",
"volume_7",
"top_drive_rpm",
"top_drive_torque",
"level_8",
"volume_8",
"pump_1_strokes",
"pump_2_strokes",
"pump_strokes_total",
"gas_3_lfl",
"pump_3_strokes",
"level_9",
"volume_9",
"level_10",
"volume_10",
"level_11",
"volume_11",
"level_12",
"volume_12",
"level_13",
"volume_13",
"level_14",
"volume_14",
"total_volume_change",
"casing_tong_1_torque",
"casing_tong_2_torque",
"drill_pipe_tong_hydraulic_pressure",
"drill_pipe_tong_high_low_speed",
"hydraulic_distributor_section_1",
"hydraulic_distributor_section_2",
"tong_type",
"gas_4_lfl",
"gas_5_lfl",
"gas_6_lfl",
"gas_7_lfl",
"gas_8_lfl",
"guyline_tension_1",
"guyline_tension_2",
"guyline_tension_3",
"guyline_tension_4",
"gas_9_lfl_ch4",
"gas_10_lfl_ch4",
"gas_1_lfl_c3h8",
"gas_2_lfl_c3h8",
"gas_3_lfl_c3h8",
"gas_4_lfl_c3h8",
"gas_5_lfl_c3h8",
"gas_6_lfl_c3h8",
"gas_7_lfl_c3h8",
"gas_8_lfl_c3h8",
"gas_9_lfl_c3h8",
"gas_10_lfl_c3h8",
"rotary_torque_2",
"density_7",
"density_8",
"density_9",
"density_10",
"bit_position_above_bottom",
"disperser_pump_pressure",
"disperser_pump_strokes",
"disperser_pump_outlet_flow",
"pump_1_outlet_flow",
"pump_2_outlet_flow",
"drilling_line_runtime",
"tripping_fill_volume_total",
"pump_1_outlet_pressure",
"pump_2_outlet_pressure",
"drilling_time_per_meter",
"gas_11_lfl_ch4",
"gas_12_lfl_ch4",
"gas_13_lfl_ch4",
"pump_3_outlet_flow",
"pump_3_outlet_pressure",
"gas_5_h2s",
"gas_6_h2s",
"gas_7_h2s"
]
}

View File

@@ -0,0 +1,130 @@
{
"edge": "edge5",
"tags": [
"Вес на крюке 1",
"Положение крюкоблока",
"Давление в манифольде 1",
"Момент на гидроключе",
"Вес на крюке 2",
"Расход на выходе",
"Загазованность 1 (НКПР)",
"Загазованность 1 (H2S)",
"Загазованность 2 (H2S)",
"Загазованность 3 (H2S)",
"Температура на выходе",
"Расход на входе 1",
"Момент на ключе АКБ",
"Момент на роторе",
"Обороты ротора",
"Давление в манифольде 2",
"Момент на машинном ключе",
"Температура на входе",
"Загазованность 4 (H2S)",
"Плотность 1",
"Плотность 2",
"Плотность 3",
"Плотность 4",
"Плотность 5",
"Плотность 6",
"Уровень 1",
"Уровень 2",
"Уровень 3",
"Уровень 4",
"Уровень 5",
"Уровень 6",
"Объем 1",
"Объем 2",
"Объем 3",
"Объем 4",
"Объем 5",
"Объем 6",
"Объем суммарный",
"Нагрузка на долото",
"Скорость проходки",
"Скорость СПО",
"Глубина инструмента",
"Глубина забоя",
"Изм. расхода на выходе",
"Подача свечи",
"Количество опущенных свеч",
"Расход на входе 2",
"Положение клиньев",
"Загазованность 2 (НКПР)",
"Уровень 7",
"Объем 7",
"Обороты СВП",
"Крутящий момент СВП",
"Уровень 8",
"Объем 8",
"Ходы насоса 1",
"Ходы насоса 2",
"Сумма ходов насосов",
"Загазованность 3 (НКПР)",
"Ходы насоса 3",
"Уровень 9",
"Объем 9",
"Уровень 10",
"Объем 10",
"Уровень 11",
"Объем 11",
"Уровень 12",
"Объем 12",
"Уровень 13",
"Объем 13",
"Уровень 14",
"Объем 14",
"Изменение суммарного объема (±)",
"Момент на ключе 1 обсадных труб",
"Момент на ключе 2 обсадных труб",
"Давление в гидросистеме ключа для бурильных труб",
"Высокая/низкая скорость ключа бурильных труб",
"Секция 1 гидрораспределителя",
"Секция 2 гидрораспределителя",
"Тип ключа",
"Загазованность 4 (НКПР)",
"Загазованность 5 (НКПР)",
"Загазованность 6 (НКПР)",
"Загазованность 7 (НКПР)",
"Загазованность 8 (НКПР)",
"Усилие в растяжке 1",
"Усилие в растяжке 2",
"Усилие в растяжке 3",
"Усилие в растяжке 4",
"Загазованность 9 (НКПР) CH4",
"Загазованность 10 (НКПР) CH4",
"Загазованность 1 (НКПР) C3H8",
"Загазованность 2 (НКПР) C3H8",
"Загазованность 3 (НКПР) C3H8",
"Загазованность 4 (НКПР) C3H8",
"Загазованность 5 (НКПР) C3H8",
"Загазованность 6 (НКПР) C3H8",
"Загазованность 7 (НКПР) C3H8",
"Загазованность 8 (НКПР) C3H8",
"Загазованность 9 (НКПР) C3H8",
"Загазованность 10 (НКПР) C3H8",
"Момент на роторе 2",
"Плотность 7",
"Плотность 8",
"Плотность 9",
"Плотность 10",
"Положение долота над забоем",
"Давление насоса диспергации",
"Число ходов насоса диспергации",
"Расход на выходе насоса диспергации",
"Расход на выходе насоса 1",
"Расход на выходе насоса 2",
"Наработка талевого каната (в реальном времени)",
"Суммарный объем долива скважины при СПО",
"Давление на выходе насоса 1",
"Давление на выходе насоса 2",
"Время бурения 1м проходки",
"Загазованность 11 (НКПР) CH4",
"Загазованность 12 (НКПР) CH4",
"Загазованность 13 (НКПР) CH4",
"Расход на выходе насоса 3",
"Давление на выходе насоса 3",
"Загазованность 5 (H2S)",
"Загазованность 6 (H2S)",
"Загазованность 7 (H2S)"
]
}

View File

@@ -0,0 +1,630 @@
{
"edge": "edge5",
"tags": [
{
"key": "hook_weight_1",
"name": "Вес на крюке 1",
"unit": "тс"
},
{
"key": "hookblock_position",
"name": "Положение крюкоблока",
"unit": "м"
},
{
"key": "manifold_pressure_1",
"name": "Давление в манифольде 1",
"unit": "МПа"
},
{
"key": "hydraulic_tong_torque",
"name": "Момент на гидроключе",
"unit": "кг*м"
},
{
"key": "hook_weight_2",
"name": "Вес на крюке 2",
"unit": "тс"
},
{
"key": "outlet_flow",
"name": "Расход на выходе",
"unit": "%"
},
{
"key": "gas_1_lfl",
"name": "Загазованность 1 (НКПР)",
"unit": "%"
},
{
"key": "gas_1_h2s",
"name": "Загазованность 1 (H2S)",
"unit": "мг/м3"
},
{
"key": "gas_2_h2s",
"name": "Загазованность 2 (H2S)",
"unit": "мг/м3"
},
{
"key": "gas_3_h2s",
"name": "Загазованность 3 (H2S)",
"unit": "мг/м3"
},
{
"key": "outlet_temperature",
"name": "Температура на выходе",
"unit": "гр.C"
},
{
"key": "inlet_flow_1",
"name": "Расход на входе 1",
"unit": "л/сек"
},
{
"key": "akb_tong_torque",
"name": "Момент на ключе АКБ",
"unit": "кН*м"
},
{
"key": "rotary_torque",
"name": "Момент на роторе",
"unit": "кН*м"
},
{
"key": "rotary_rpm",
"name": "Обороты ротора",
"unit": "об/мин"
},
{
"key": "manifold_pressure_2",
"name": "Давление в манифольде 2",
"unit": "МПа"
},
{
"key": "machine_tong_torque",
"name": "Момент на машинном ключе",
"unit": "кН*м"
},
{
"key": "inlet_temperature",
"name": "Температура на входе",
"unit": "гр.C"
},
{
"key": "gas_4_h2s",
"name": "Загазованность 4 (H2S)",
"unit": "мг/м3"
},
{
"key": "density_1",
"name": "Плотность 1",
"unit": "г/см3"
},
{
"key": "density_2",
"name": "Плотность 2",
"unit": "г/см3"
},
{
"key": "density_3",
"name": "Плотность 3",
"unit": "г/см3"
},
{
"key": "density_4",
"name": "Плотность 4",
"unit": "г/см3"
},
{
"key": "density_5",
"name": "Плотность 5",
"unit": "г/см3"
},
{
"key": "density_6",
"name": "Плотность 6",
"unit": "г/см3"
},
{
"key": "level_1",
"name": "Уровень 1",
"unit": "см"
},
{
"key": "level_2",
"name": "Уровень 2",
"unit": "см"
},
{
"key": "level_3",
"name": "Уровень 3",
"unit": "см"
},
{
"key": "level_4",
"name": "Уровень 4",
"unit": "см"
},
{
"key": "level_5",
"name": "Уровень 5",
"unit": "см"
},
{
"key": "level_6",
"name": "Уровень 6",
"unit": "см"
},
{
"key": "volume_1",
"name": "Объем 1",
"unit": "м3"
},
{
"key": "volume_2",
"name": "Объем 2",
"unit": "м3"
},
{
"key": "volume_3",
"name": "Объем 3",
"unit": "м3"
},
{
"key": "volume_4",
"name": "Объем 4",
"unit": "м3"
},
{
"key": "volume_5",
"name": "Объем 5",
"unit": "м3"
},
{
"key": "volume_6",
"name": "Объем 6",
"unit": "м3"
},
{
"key": "total_volume",
"name": "Объем суммарный",
"unit": "м3"
},
{
"key": "weight_on_bit",
"name": "Нагрузка на долото",
"unit": "тс"
},
{
"key": "penetration_rate",
"name": "Скорость проходки",
"unit": "м/ч"
},
{
"key": "tripping_speed",
"name": "Скорость СПО",
"unit": "м/с"
},
{
"key": "tool_depth",
"name": "Глубина инструмента",
"unit": "м"
},
{
"key": "bottom_depth",
"name": "Глубина забоя",
"unit": "м"
},
{
"key": "outlet_flow_change",
"name": "Изм. расхода на выходе",
"unit": "%"
},
{
"key": "stand_feed",
"name": "Подача свечи",
"unit": "м"
},
{
"key": "lowered_stands_count",
"name": "Количество опущенных свеч",
"unit": ""
},
{
"key": "inlet_flow_2",
"name": "Расход на входе 2",
"unit": "л/сек"
},
{
"key": "slips_position",
"name": "Положение клиньев",
"unit": ""
},
{
"key": "gas_2_lfl",
"name": "Загазованность 2 (НКПР)",
"unit": "%"
},
{
"key": "level_7",
"name": "Уровень 7",
"unit": "см"
},
{
"key": "volume_7",
"name": "Объем 7",
"unit": "м3"
},
{
"key": "top_drive_rpm",
"name": "Обороты СВП",
"unit": "об/мин"
},
{
"key": "top_drive_torque",
"name": "Крутящий момент СВП",
"unit": "кНм"
},
{
"key": "level_8",
"name": "Уровень 8",
"unit": "см"
},
{
"key": "volume_8",
"name": "Объем 8",
"unit": "м3"
},
{
"key": "pump_1_strokes",
"name": "Ходы насоса 1",
"unit": "ход/мин"
},
{
"key": "pump_2_strokes",
"name": "Ходы насоса 2",
"unit": "ход/мин"
},
{
"key": "pump_strokes_total",
"name": "Сумма ходов насосов",
"unit": "ход/мин"
},
{
"key": "gas_3_lfl",
"name": "Загазованность 3 (НКПР)",
"unit": "%"
},
{
"key": "pump_3_strokes",
"name": "Ходы насоса 3",
"unit": "ход/мин"
},
{
"key": "level_9",
"name": "Уровень 9",
"unit": "см"
},
{
"key": "volume_9",
"name": "Объем 9",
"unit": "м3"
},
{
"key": "level_10",
"name": "Уровень 10",
"unit": "см"
},
{
"key": "volume_10",
"name": "Объем 10",
"unit": "м3"
},
{
"key": "level_11",
"name": "Уровень 11",
"unit": "см"
},
{
"key": "volume_11",
"name": "Объем 11",
"unit": "м3"
},
{
"key": "level_12",
"name": "Уровень 12",
"unit": "см"
},
{
"key": "volume_12",
"name": "Объем 12",
"unit": "м3"
},
{
"key": "level_13",
"name": "Уровень 13",
"unit": "см"
},
{
"key": "volume_13",
"name": "Объем 13",
"unit": "м3"
},
{
"key": "level_14",
"name": "Уровень 14",
"unit": "см"
},
{
"key": "volume_14",
"name": "Объем 14",
"unit": "м3"
},
{
"key": "total_volume_change",
"name": "Изменение суммарного объема (±)",
"unit": "м3"
},
{
"key": "casing_tong_1_torque",
"name": "Момент на ключе 1 обсадных труб",
"unit": "кгм"
},
{
"key": "casing_tong_2_torque",
"name": "Момент на ключе 2 обсадных труб",
"unit": "кгм"
},
{
"key": "drill_pipe_tong_hydraulic_pressure",
"name": "Давление в гидросистеме ключа для бурильных труб",
"unit": "МПа"
},
{
"key": "drill_pipe_tong_high_low_speed",
"name": "Высокая/низкая скорость ключа бурильных труб",
"unit": ""
},
{
"key": "hydraulic_distributor_section_1",
"name": "Секция 1 гидрораспределителя",
"unit": ""
},
{
"key": "hydraulic_distributor_section_2",
"name": "Секция 2 гидрораспределителя",
"unit": ""
},
{
"key": "tong_type",
"name": "Тип ключа",
"unit": ""
},
{
"key": "gas_4_lfl",
"name": "Загазованность 4 (НКПР)",
"unit": "%"
},
{
"key": "gas_5_lfl",
"name": "Загазованность 5 (НКПР)",
"unit": "%"
},
{
"key": "gas_6_lfl",
"name": "Загазованность 6 (НКПР)",
"unit": "%"
},
{
"key": "gas_7_lfl",
"name": "Загазованность 7 (НКПР)",
"unit": "%"
},
{
"key": "gas_8_lfl",
"name": "Загазованность 8 (НКПР)",
"unit": "%"
},
{
"key": "guyline_tension_1",
"name": "Усилие в растяжке 1",
"unit": "кг"
},
{
"key": "guyline_tension_2",
"name": "Усилие в растяжке 2",
"unit": "кг"
},
{
"key": "guyline_tension_3",
"name": "Усилие в растяжке 3",
"unit": "кг"
},
{
"key": "guyline_tension_4",
"name": "Усилие в растяжке 4",
"unit": "кг"
},
{
"key": "gas_9_lfl_ch4",
"name": "Загазованность 9 (НКПР) CH4",
"unit": "%"
},
{
"key": "gas_10_lfl_ch4",
"name": "Загазованность 10 (НКПР) CH4",
"unit": "%"
},
{
"key": "gas_1_lfl_c3h8",
"name": "Загазованность 1 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_2_lfl_c3h8",
"name": "Загазованность 2 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_3_lfl_c3h8",
"name": "Загазованность 3 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_4_lfl_c3h8",
"name": "Загазованность 4 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_5_lfl_c3h8",
"name": "Загазованность 5 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_6_lfl_c3h8",
"name": "Загазованность 6 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_7_lfl_c3h8",
"name": "Загазованность 7 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_8_lfl_c3h8",
"name": "Загазованность 8 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_9_lfl_c3h8",
"name": "Загазованность 9 (НКПР) C3H8",
"unit": "%"
},
{
"key": "gas_10_lfl_c3h8",
"name": "Загазованность 10 (НКПР) C3H8",
"unit": "%"
},
{
"key": "rotary_torque_2",
"name": "Момент на роторе 2",
"unit": "кН*м"
},
{
"key": "density_7",
"name": "Плотность 7",
"unit": "г/см3"
},
{
"key": "density_8",
"name": "Плотность 8",
"unit": "г/см3"
},
{
"key": "density_9",
"name": "Плотность 9",
"unit": "г/см3"
},
{
"key": "density_10",
"name": "Плотность 10",
"unit": "г/см3"
},
{
"key": "bit_position_above_bottom",
"name": "Положение долота над забоем",
"unit": "м"
},
{
"key": "disperser_pump_pressure",
"name": "Давление насоса диспергации",
"unit": "МПа"
},
{
"key": "disperser_pump_strokes",
"name": "Число ходов насоса диспергации",
"unit": "ход/мин"
},
{
"key": "disperser_pump_outlet_flow",
"name": "Расход на выходе насоса диспергации",
"unit": "л/с"
},
{
"key": "pump_1_outlet_flow",
"name": "Расход на выходе насоса 1",
"unit": "л/с"
},
{
"key": "pump_2_outlet_flow",
"name": "Расход на выходе насоса 2",
"unit": "л/с"
},
{
"key": "drilling_line_runtime",
"name": "Наработка талевого каната (в реальном времени)",
"unit": "час."
},
{
"key": "tripping_fill_volume_total",
"name": "Суммарный объем долива скважины при СПО",
"unit": "м3"
},
{
"key": "pump_1_outlet_pressure",
"name": "Давление на выходе насоса 1",
"unit": "МПа"
},
{
"key": "pump_2_outlet_pressure",
"name": "Давление на выходе насоса 2",
"unit": "МПа"
},
{
"key": "drilling_time_per_meter",
"name": "Время бурения 1м проходки",
"unit": "час."
},
{
"key": "gas_11_lfl_ch4",
"name": "Загазованность 11 (НКПР) CH4",
"unit": "%"
},
{
"key": "gas_12_lfl_ch4",
"name": "Загазованность 12 (НКПР) CH4",
"unit": "%"
},
{
"key": "gas_13_lfl_ch4",
"name": "Загазованность 13 (НКПР) CH4",
"unit": "%"
},
{
"key": "pump_3_outlet_flow",
"name": "Расход на выходе насоса 3",
"unit": "л/с"
},
{
"key": "pump_3_outlet_pressure",
"name": "Давление на выходе насоса 3",
"unit": "МПа"
},
{
"key": "gas_5_h2s",
"name": "Загазованность 5 (H2S)",
"unit": "мг/м3"
},
{
"key": "gas_6_h2s",
"name": "Загазованность 6 (H2S)",
"unit": "мг/м3"
},
{
"key": "gas_7_h2s",
"name": "Загазованность 7 (H2S)",
"unit": "мг/м3"
}
]
}

45
app/assets/index.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>edge5 video relay</title>
<script src="https://cdn.jsdelivr.net/npm/mpegts.js/dist/mpegts.js"></script>
<style>
body { margin: 0; background: #000; }
#v { display: block; width: 100vw; height: 100vh; object-fit: contain; }
</style>
</head>
<body>
<video id="v" controls autoplay muted></video>
<script>
const cameraId = new URLSearchParams(location.search).get('camera') ?? 'v1';
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${cameraId}`;
const player = mpegts.createPlayer(
{ type: 'mpegts', isLive: true, url: wsUrl, hasAudio: false },
{
enableWorker: true,
// Включаем stash, чтобы сглаживать редкий / рваный поток
enableStashBuffer: true,
stashInitialSize: 512, // немного увеличенный stash для накопления
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 10, // держим минимум истории
autoCleanupMinBackwardDuration: 5,
// Разрешаем latency-chasing с «комфортной» задержкой
liveBufferLatencyChasing: true,
liveBufferLatencyMaxLatency: 5.0, // целевая задержка до ~5 секунд
liveBufferLatencyMinRemain: 2.0, // оставляем ~2 секунды буфера после сброса
liveSync: false, // не пытаемся мягко догонять — приоритет стабильности
lazyLoad: false,
deferLoadAfterSourceOpen: false,
}
);
player.attachMediaElement(document.getElementById('v'))
player.load()
player.play()
</script>
</body>
</html>

38
app/package-lock.json generated
View File

@@ -6,11 +6,13 @@
"": {
"name": "mqtt-worker",
"dependencies": {
"@types/node": "^25.9.1",
"mqtt": "^5.15.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
},
"node_modules/@babel/runtime": {
@@ -29,6 +31,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -45,6 +48,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -61,6 +65,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -77,6 +82,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -93,6 +99,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -109,6 +116,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -125,6 +133,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -141,6 +150,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -157,6 +167,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -173,6 +184,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -189,6 +201,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -205,6 +218,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -221,6 +235,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -237,6 +252,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -253,6 +269,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -269,6 +286,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -285,6 +303,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -301,6 +320,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -317,6 +337,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -333,6 +354,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -349,6 +371,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -365,6 +388,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -381,6 +405,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -397,6 +422,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -413,6 +439,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -429,6 +456,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -607,6 +635,7 @@
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -679,6 +708,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -923,6 +953,7 @@
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
@@ -947,6 +978,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@@ -3,16 +3,17 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"dev": "tsx watch --env-file=.env src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit"
"start": "node --env-file=.env dist/index.js"
},
"dependencies": {
"@types/node": "^25.9.1",
"mqtt": "^5.15.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
}

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>edge5 video relay</title>
<script src="https://cdn.jsdelivr.net/npm/mpegts.js/dist/mpegts.js"></script>
</head>
<body>
<video id="v" controls autoplay muted style="width:100%"></video>
<script>
const wsUrl = `ws://${location.hostname}:9090`
const player = mpegts.createPlayer(
{ type: 'mpegts', isLive: true, url: wsUrl },
{ liveBufferLatencyChasing: true, liveBufferLatencyMaxLatency: 1.5 }
)
player.attachMediaElement(document.getElementById('v'))
player.load()
player.play()
</script>
</body>
</html>

24
app/src/cloud-ingest.ts Normal file
View File

@@ -0,0 +1,24 @@
export const cloudIngestUrl = process.env.CLOUD_INGEST_URL as string
export const demoCloudIngestUrl = process.env.DEMO_CLOUD_INGEST_URL as string
export function postCloudIngest(body: unknown): Promise<Response> {
return fetch(cloudIngestUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.CLOUD_INGEST_API_KEY as string,
},
body: JSON.stringify(body),
})
}
export function postDemoCloudIngest(body: unknown): Promise<Response> {
return fetch(demoCloudIngestUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.CLOUD_INGEST_API_KEY as string,
},
body: JSON.stringify(body),
})
}

View File

@@ -0,0 +1,62 @@
import { log } from '../helpers/log.js'
import type { TopicHandler } from '../types.js'
const EDGE = 'demo'
const REGISTER_COUNT = 125
const DEMO_CLOUD_INGEST_URL = process.env.DEMO_CLOUD_INGEST_URL as string
type DemoModbusMetric = {
edge: string
timestamp: string
tag: string
value: number
}
function parseValues(payload: Buffer): number[] {
const values = JSON.parse(payload.toString('utf8')) as number[]
assertRegisterCount(values)
return values
}
function assertRegisterCount(values: number[]): void {
if (values.length !== REGISTER_COUNT) {
throw new Error(`Expected ${REGISTER_COUNT} modbus values, got ${values.length}`)
}
}
function toMetrics(values: number[], timestamp: string): DemoModbusMetric[] {
return values.map((value, index) => ({
edge: EDGE,
timestamp,
tag: `register-${index}`,
value,
}))
}
async function postMetrics(metrics: DemoModbusMetric[]): Promise<void> {
for (const metric of metrics) {
const response = await fetch(DEMO_CLOUD_INGEST_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.CLOUD_INGEST_API_KEY as string,
},
body: JSON.stringify(metric),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text)
}
}
log(`${EDGE}.modbus.posted ${metrics.length} metrics`)
}
export const handleDemoModbus: TopicHandler = async (topic, payload) => {
log(`${EDGE}.modbus.received ${topic} ${payload.length} bytes`)
const values = parseValues(payload)
const metrics = toMetrics(values, new Date().toISOString())
await postMetrics(metrics)
}

View File

@@ -0,0 +1,53 @@
import { log } from '../helpers/log.js'
import { parseJson } from '../helpers/parse.js'
import type { TopicHandler } from '../types.js'
const EDGE = 'demo'
const DEMO_CLOUD_INGEST_URL = process.env.DEMO_CLOUD_INGEST_URL as string
type DemoPlcPayload = {
tag: string
value: number
}
type DemoPlcMetric = {
edge: string
timestamp: string
tag: string
value: number
}
function toMetric(payload: DemoPlcPayload, timestamp: string): DemoPlcMetric {
return {
edge: EDGE,
timestamp,
tag: payload.tag,
value: payload.value,
}
}
async function postMetric(metric: DemoPlcMetric): Promise<void> {
const response = await fetch(DEMO_CLOUD_INGEST_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.CLOUD_INGEST_API_KEY as string,
},
body: JSON.stringify(metric),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text)
}
log('demo.plc.posted', { tag: metric.tag })
}
export const handleDemoPlc: TopicHandler = async (topic, payload) => {
log('demo.plc.received', { topic, bytes: payload.length })
const value = parseJson<DemoPlcPayload>(payload)
const metric = toMetric(value, new Date().toISOString())
await postMetric(metric)
}

View File

@@ -0,0 +1,67 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { postCloudIngest } from '../cloud-ingest.js'
import { log } from '../helpers/log.js'
import { createMetricMapper, type MappedMetric } from '../mapping.js'
import type { TopicHandler } from '../types.js'
const REGISTER_COUNT = 125
const defaultMappingFile = join(
dirname(fileURLToPath(import.meta.url)),
'../../assets/edge5-modbus.json',
)
const mapper = createMetricMapper({
filePath: process.env.EDGE5_MODBUS_V2_MAPPING_FILE ?? defaultMappingFile,
})
function parseValues(payload: Buffer): number[] {
const values = JSON.parse(payload.toString('utf8')) as number[]
assertRegisterCount(values)
return values
}
function assertRegisterCount(values: number[]): void {
if (values.length !== REGISTER_COUNT) {
throw new Error(`Expected ${REGISTER_COUNT} modbus values, got ${values.length}`)
}
}
async function postMetrics(metrics: MappedMetric[]): Promise<void> {
const errors: Error[] = []
for (const metric of metrics) {
try {
const response = await postCloudIngest(metric)
if (!response.ok) {
const text = await response.text()
throw new Error(text || `${response.status} ${response.statusText}`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
errors.push(new Error(`${metric.tag}: ${message}`))
console.error('edge5.modbus.v2.metric.failed', metric.tag, message)
}
}
log('edge5.modbus.v2.posted', {
total: metrics.length,
succeeded: metrics.length - errors.length,
failed: errors.length,
})
if (errors.length) {
throw new Error(`Failed to post ${errors.length}/${metrics.length} edge5 modbus v2 metrics`)
}
}
export const handleEdge5ModbusV2: TopicHandler = async (topic, payload) => {
log('edge5.modbus.v2.received', { topic, bytes: payload.length })
const values = parseValues(payload)
const metrics = mapper.mapValues(values, new Date().toISOString())
await postMetrics(metrics)
}

View File

@@ -0,0 +1,113 @@
import { postCloudIngest, postDemoCloudIngest } from '../cloud-ingest.js'
import { log } from '../helpers/log.js'
import type { TopicHandler } from '../types.js'
const EDGE = 'edge5-v3'
const REGISTER_COUNT = 24
// Фиксируем интерпретацию
const TAGS = [
'edge5-v3-wk',
'edge5-v3-pin',
'edge5-v3-hk',
'edge5-v3-mk',
'edge5-v3-mrot',
'edge5-v3-h',
'edge5-v3-vw',
'edge5-v3-pdk',
'edge5-v3-h2s',
'edge5-v3-t',
'edge5-v3-nrot',
'edge5-v3-vsp',
] as const
// из них выводим на дашборд семь виджетов
const CLOUD_INGEST_TAGS = new Set<string>([
'edge5-v3-wk',
'edge5-v3-hk',
'edge5-v3-vw',
'edge5-v3-pdk',
'edge5-v3-h2s',
'edge5-v3-nrot',
'edge5-v3-vsp',
])
type Edge5ModbusV3Metric = {
edge: string
timestamp: string
tag: string
value: number | null
}
function toFloatCDAB(reg0: number, reg1: number): number | null {
const buf = Buffer.allocUnsafe(4)
buf.writeUInt16BE(reg1, 0)
buf.writeUInt16BE(reg0, 2)
return buf.readFloatBE(0)
}
function parseValues(payload: Buffer): number[] {
const values = JSON.parse(payload.toString('utf8')) as number[]
assertRegisterCount(values)
return values
}
function assertRegisterCount(values: number[]): void {
if (values.length !== REGISTER_COUNT) {
throw new Error(`Expected ${REGISTER_COUNT} modbus values, got ${values.length}`)
}
}
function toMetrics(values: number[], timestamp: string): Edge5ModbusV3Metric[] {
const metrics: Edge5ModbusV3Metric[] = []
for (let i = 0; i < values.length; i += 2) {
const tag = TAGS[i / 2]
const value = toFloatCDAB(values[i], values[i + 1]); // null is possible to send
metrics.push({
edge: EDGE,
timestamp,
tag,
value,
})
}
return metrics
}
async function postMetrics(metrics: Edge5ModbusV3Metric[]): Promise<void> {
const toPost = metrics.filter((metric) => CLOUD_INGEST_TAGS.has(metric.tag))
const errors: Error[] = []
for (const metric of toPost) {
try {
const response = await postCloudIngest(metric)
if (!response.ok) {
const text = await response.text()
throw new Error(text || `${response.status} ${response.statusText}`)
}
void postDemoCloudIngest(metric); // для демо-системы
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
errors.push(new Error(`${metric.tag}: ${message}`))
console.error('edge5.modbus.v3.metric.failed', metric.tag, message)
}
}
log(`edge5.modbus.v3.data ↑ ${toPost.length} tags`);
if (errors.length) {
throw new Error(`Failed to post ${errors.length}/${toPost.length} edge5 modbus v3 metrics`)
}
}
export const handleEdge5ModbusV3: TopicHandler = async (topic, payload) => {
const timestamp = new Date().toISOString();
const values = parseValues(payload);
const metrics = toMetrics(values, timestamp);
await postMetrics(metrics);
}

View File

@@ -1,88 +0,0 @@
import type { TopicHandler } from '../types.js'
export const TOPIC = 'data/edge5/modbus/v1'
const EDGE = 'edge5'
const REGISTER_COUNT = 125
const postUrl =
process.env.CLOUD_INGEST_URL ?? 'https://greact.drll.cloud/api/ingest'
type Edge5ModbusMetric = {
edge: string
timestamp: number
tag: string
value: number
}
function parseValues(payload: Buffer): number[] {
let value: unknown
try {
value = JSON.parse(payload.toString('utf8')) as unknown
} catch {
throw new Error(`Expected JSON number[${REGISTER_COUNT}]`)
}
if (
Array.isArray(value) &&
value.length === REGISTER_COUNT &&
value.every((item) => typeof item === 'number' && Number.isFinite(item))
) {
return value
}
throw new Error(`Expected JSON number[${REGISTER_COUNT}]`)
}
function toMetrics(values: number[], timestamp: number): Edge5ModbusMetric[] {
return values.map((value, index) => ({
edge: EDGE,
timestamp,
tag: `${EDGE}.modbus.${index + 1}`,
value,
}))
}
async function postMetrics(metrics: Edge5ModbusMetric[]): Promise<void> {
// console.log('edge5.modbus.post_payload', JSON.stringify(metrics))
// console.log('edge5.modbus.post_skipped', {
// url: postUrl,
// count: metrics.length,
// })
const response = await fetch(postUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(metrics),
})
if (!response.ok) {
const text = await response.text()
console.error('edge5.modbus.post_error', {
status: response.status,
statusText: response.statusText,
body: text.slice(0, 500),
})
return
}
console.log('edge5.modbus.posted', { count: metrics.length })
}
export const handleEdge5Modbus: TopicHandler = async (topic, payload) => {
console.log('edge5.modbus.received', { topic, bytes: payload.length })
let values: number[]
try {
values = parseValues(payload)
} catch (err) {
console.warn('edge5.modbus.invalid_payload', { topic, err })
return
}
const metrics = toMetrics(values, Date.now())
await postMetrics(metrics)
}

View File

@@ -0,0 +1,21 @@
import { log } from '../helpers/log.js'
import { lastTopicSegment } from '../helpers/topic-match.js'
import { onEdgeChunk } from '../stream/edge-chunk-relay.js'
import type { TopicHandler } from '../types.js'
const LOG_INTERVAL_MS = 10_000
const lastLogByCamera = new Map<string, number>()
export const handleEdge5VideoV2: TopicHandler = async (topic, payload) => {
const cameraId = lastTopicSegment(topic)
const now = Date.now()
const lastLog = lastLogByCamera.get(cameraId) ?? 0
if (now - lastLog >= LOG_INTERVAL_MS) {
log(`edge5.video.v2 [${cameraId}] ↑ ${payload.length}B`)
lastLogByCamera.set(cameraId, now)
}
onEdgeChunk(cameraId, payload);
}

View File

@@ -1,16 +1,15 @@
import { log } from '../helpers/log.js'
import { onEdgeChunk } from '../stream/edge-chunk-relay.js'
import type { TopicHandler } from '../types.js'
export const TOPIC = 'data/edge5/video/v1'
const BEACON_MS = 3000
let lastBeacon = 0
const LOG_INTERVAL_MS = 10_000
let lastLog = 0
export const handleEdge5Video: TopicHandler = async (_topic, payload) => {
const now = Date.now()
if (now - lastBeacon >= BEACON_MS) {
console.log(`edge5.video ↑ ${payload.length}B`)
lastBeacon = now
if (now - lastLog >= LOG_INTERVAL_MS) {
log(`edge5.video ↑ ${payload.length}B`)
lastLog = now
}
onEdgeChunk(payload)
}
onEdgeChunk('v1', payload);
}

View File

@@ -0,0 +1,72 @@
import { postCloudIngest } from '../cloud-ingest.js'
import { log } from '../helpers/log.js'
import type { TopicHandler } from '../types.js'
const EDGE = 'edge5cdab'
const REGISTER_COUNT = 100
type Edge5iiModbusMetric = {
edge: string
timestamp: string
tag: string
value: number | null
};
function toFloatCDAB(reg0: number, reg1: number): number | null {
const buf = Buffer.allocUnsafe(4)
buf.writeUInt16BE(reg1, 0)
buf.writeUInt16BE(reg0, 2)
return buf.readFloatBE(0)
}
function parseValues(payload: Buffer): number[] {
const values = JSON.parse(payload.toString('utf8')) as number[]
assertRegisterCount(values)
return values
}
function assertRegisterCount(values: number[]): void {
if (values.length !== REGISTER_COUNT) {
throw new Error(`Expected ${REGISTER_COUNT} modbus values, got ${values.length}`)
}
}
function toMetrics(values: number[], timestamp: string): Edge5iiModbusMetric[] {
const metrics: Edge5iiModbusMetric[] = []
for (let i = 0; i < values.length; i += 2) {
const tag = `register-${String(i).padStart(3, '0')}`
const value = toFloatCDAB(values[i], values[i + 1]); // null is possible to send
metrics.push({
edge: EDGE,
timestamp,
tag,
value,
})
}
return metrics
}
async function postMetrics(metrics: Edge5iiModbusMetric[]): Promise<void> {
for (const metric of metrics) {
const response = await postCloudIngest(metric)
if (!response.ok) {
const text = await response.text()
throw new Error(text)
}
}
log(`${EDGE}.modbus.posted ${metrics.length} metrics`)
}
export const handle: TopicHandler = async (topic, payload) => {
const timestamp = new Date().toISOString()
log(`${EDGE}.modbus.received ${topic} ${payload.length} bytes`)
const values = parseValues(payload)
const metrics = toMetrics(values, timestamp);
await postMetrics(metrics)
}

View File

@@ -0,0 +1,55 @@
import { postCloudIngest } from '../cloud-ingest.js'
import { log } from '../helpers/log.js'
import type { TopicHandler } from '../types.js'
const EDGE = 'edge5i'
const REGISTER_COUNT = 100
type DemoModbusMetric = {
edge: string
timestamp: string
tag: string
value: number
}
function parseValues(payload: Buffer): number[] {
const values = JSON.parse(payload.toString('utf8')) as number[]
assertRegisterCount(values)
return values
}
function assertRegisterCount(values: number[]): void {
if (values.length !== REGISTER_COUNT) {
throw new Error(`Expected ${REGISTER_COUNT} modbus values, got ${values.length}`)
}
}
function toMetrics(values: number[], timestamp: string): DemoModbusMetric[] {
return values.map((value, index) => ({
edge: EDGE,
timestamp,
tag: `register-${String(index).padStart(3, '0')}`,
value,
}))
}
async function postMetrics(metrics: DemoModbusMetric[]): Promise<void> {
for (const metric of metrics) {
const response = await postCloudIngest(metric)
if (!response.ok) {
const text = await response.text()
throw new Error(text)
}
}
log(`${EDGE}.modbus.posted ${metrics.length} metrics`)
}
export const handle: TopicHandler = async (topic, payload) => {
log(`${EDGE}.modbus.received ${topic} ${payload.length} bytes`)
const values = parseValues(payload)
const metrics = toMetrics(values, new Date().toISOString())
await postMetrics(metrics)
}

View File

@@ -1,16 +1,26 @@
import { register } from '../registry.js'
import { handleTopic1, TOPIC as topic1 } from './topic1.js'
import { handleTopic2, TOPIC as topic2 } from './topic2.js'
import { handleTopic3, TOPIC as topic3 } from './topic3.js'
import { handleTopic4, TOPIC as topic4 } from './topic4.js'
import { handleTopic5, TOPIC as topic5 } from './topic5.js'
import { handleEdge5Modbus, TOPIC as edge5Modbus } from './edge5-modbus.js'
import { handleEdge5Video, TOPIC as edge5Video } from './edge5-video.js'
import { register } from '../registry.js';
register(topic1, handleTopic1)
register(topic2, handleTopic2)
register(topic3, handleTopic3)
register(topic4, handleTopic4)
register(topic5, handleTopic5)
register(edge5Modbus, handleEdge5Modbus)
register(edge5Video, handleEdge5Video)
import { handleDemoPlc } from './demo-plc.js';
import { handleDemoModbus } from './demo-modbus.js';
import { handleEdge5ModbusV2 } from './edge5-modbus-v2.js';
import { handleEdge5ModbusV3 } from './edge5-modbus-v3.js';
import { handleEdge5Video } from './edge5-video.js';
import { handleEdge5VideoV2 } from './edge5-video-v2.js';
import { handle as interpretation } from './edge5i-modbus.js';
import { handle as cdab} from './edge5cdab-modbus.js';
const DEMO_PLC_TOPIC = 'data/demo/plc/v1';
const DEMO_MODBUS_TOPIC = 'data/demo/modbus/v1';
const EDGE5_MODBUS_TOPIC = 'data/edge5/modbus/v2';
const EDGE5_VIDEO_TOPIC = 'data/edge5/video/v1';
register(DEMO_PLC_TOPIC, handleDemoPlc);
register(DEMO_MODBUS_TOPIC, handleDemoModbus);
register(EDGE5_MODBUS_TOPIC, handleEdge5ModbusV2);
register(EDGE5_VIDEO_TOPIC, handleEdge5Video);
register('data/edge5i/modbus', interpretation);
register('data/edge5cdab/modbus', cdab);
register('data/edge5/video/v2/+', handleEdge5VideoV2);
register('data/edge5/modbus/v3', handleEdge5ModbusV3);

View File

@@ -1,16 +0,0 @@
import { parseJson } from '../helpers/parse.js'
import { isArrayOfChunks125 } from '../helpers/validate.js'
import type { TopicHandler } from '../types.js'
export const TOPIC = 'poc/topic1'
export const handleTopic1: TopicHandler = async (topic, payload) => {
const raw = parseJson(payload)
if (!isArrayOfChunks125(raw)) {
console.warn('topic1.invalid_payload', { topic })
return
}
console.log('topic1.received', { topic, chunks: raw.length })
}

View File

@@ -1,16 +0,0 @@
import { parseJson } from '../helpers/parse.js'
import { isArrayOfLength125 } from '../helpers/validate.js'
import type { TopicHandler } from '../types.js'
export const TOPIC = 'poc/topic2'
export const handleTopic2: TopicHandler = async (topic, payload) => {
const raw = parseJson(payload)
if (!isArrayOfLength125(raw)) {
console.warn('topic2.invalid_payload', { topic })
return
}
console.log('topic2.received', { topic, length: raw.length })
}

View File

@@ -1,16 +0,0 @@
import { parseUtf8 } from '../helpers/parse.js'
import { isNonEmptyString } from '../helpers/validate.js'
import type { TopicHandler } from '../types.js'
export const TOPIC = 'poc/topic3'
export const handleTopic3: TopicHandler = async (topic, payload) => {
const text = parseUtf8(payload)
if (!isNonEmptyString(text)) {
console.warn('topic3.invalid_payload', { topic })
return
}
console.log('topic3.received', { topic, text })
}

View File

@@ -1,29 +0,0 @@
import { parseJson } from '../helpers/parse.js'
import { isFiniteNumber } from '../helpers/validate.js'
import type { TopicHandler } from '../types.js'
export const TOPIC = 'poc/topic4'
type Topic4Payload = {
value: number
}
function isTopic4Payload(value: unknown): value is Topic4Payload {
return (
typeof value === 'object' &&
value !== null &&
'value' in value &&
isFiniteNumber((value as Topic4Payload).value)
)
}
export const handleTopic4: TopicHandler = async (topic, payload) => {
const raw = parseJson(payload)
if (!isTopic4Payload(raw)) {
console.warn('topic4.invalid_payload', { topic })
return
}
console.log('topic4.received', { topic, value: raw.value })
}

View File

@@ -1,10 +0,0 @@
import { parseJsonArray } from '../helpers/parse.js'
import type { TopicHandler } from '../types.js'
export const TOPIC = 'poc/topic5'
export const handleTopic5: TopicHandler = async (topic, payload) => {
const items = parseJsonArray(payload)
console.log('topic5.received', { topic, count: items.length })
}

3
app/src/helpers/log.ts Normal file
View File

@@ -0,0 +1,3 @@
export function log(...args: unknown[]): void {
console.log(`[${new Date().toISOString()}]`, ...args)
}

View File

@@ -7,14 +7,6 @@ export function parseJson<T = unknown>(payload: Buffer): T {
return JSON.parse(text) as T
}
export function parseJsonArray(payload: Buffer): unknown[] {
const value = parseJson(payload)
if (!Array.isArray(value)) {
throw new Error('Expected JSON array')
}
return value
}
export function decodeBase64(payload: Buffer): Buffer {
return Buffer.from(payload.toString('utf8'), 'base64')
}

View File

@@ -0,0 +1,38 @@
export function topicMatchesFilter(filter: string, topic: string): boolean {
const filterParts = filter.split('/')
const topicParts = topic.split('/')
let fi = 0
let ti = 0
while (fi < filterParts.length) {
const segment = filterParts[fi]
if (segment === '#') {
return fi === filterParts.length - 1
}
if (ti >= topicParts.length) {
return false
}
if (segment === '+') {
fi++
ti++
continue
}
if (segment !== topicParts[ti]) {
return false
}
fi++
ti++
}
return ti === topicParts.length
}
export function lastTopicSegment(topic: string): string {
return topic.split('/').at(-1) ?? ''
}

View File

@@ -1,39 +0,0 @@
const EXPECTED_CHUNK_LENGTH = 125
export function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}
export function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
export function isArrayOfLength(
value: unknown,
length: number,
): value is unknown[] {
return Array.isArray(value) && value.length === length
}
export function isArrayOfLength125(value: unknown): value is unknown[] {
return isArrayOfLength(value, EXPECTED_CHUNK_LENGTH)
}
export function isArrayOfChunks125(
value: unknown,
): value is unknown[][] {
if (!Array.isArray(value)) {
return false
}
return value.every((chunk) => isArrayOfLength125(chunk))
}
export function assertValid<T>(
value: unknown,
guard: (v: unknown) => v is T,
label: string,
): asserts value is T {
if (!guard(value)) {
throw new Error(`Invalid payload: ${label}`)
}
}

View File

@@ -3,9 +3,11 @@ import { createServer } from 'node:http'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const httpPort = Number(process.env.HTTP_PORT ?? 80)
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '../../public')
const indexPath = join(publicDir, 'index.html')
import { log } from '../helpers/log.js'
const httpPort = Number(process.env.HTTP_PORT)
const assetsDir = join(dirname(fileURLToPath(import.meta.url)), '../../assets')
const indexPath = join(assetsDir, 'index.html')
createServer(async (req, res) => {
const path = req.url?.split('?')[0]
@@ -24,5 +26,5 @@ createServer(async (req, res) => {
res.writeHead(404).end('Not Found')
}).listen(httpPort, () => {
console.log('http.server_started', { port: httpPort })
log('http.server_started', { port: httpPort })
})

View File

@@ -3,4 +3,6 @@ import './http/server.js'
import './mqtt.js'
import './stream/edge-chunk-relay.js'
console.log('mqtt-worker.started')
import { log } from './helpers/log.js'
log('mqtt-worker.started')

44
app/src/mapping.ts Normal file
View File

@@ -0,0 +1,44 @@
import { readFileSync } from 'node:fs'
export type MappedMetric = {
edge: string
timestamp: string
tag: string
value: number
}
type MappingConfig = {
edge: string
tags: string[]
}
export type MetricMapper = {
edge: string
mapValues: (values: number[], timestamp: string) => MappedMetric[]
}
type MetricMapperOptions = {
// filePath contract:
// - built-in mappings live in app/assets/*.json (resolved relative to dist/ at runtime);
// - env overrides are expected as absolute paths inside the runtime container.
filePath: string | URL
}
function readMappingConfig(filePath: string | URL): MappingConfig {
return JSON.parse(readFileSync(filePath, 'utf8')) as MappingConfig
}
export function createMetricMapper(options: MetricMapperOptions): MetricMapper {
const config = readMappingConfig(options.filePath)
return {
edge: config.edge,
mapValues: (values, timestamp) =>
values.map((value, index) => ({
edge: config.edge,
timestamp,
tag: config.tags[index],
value,
})),
}
}

View File

@@ -1,14 +1,20 @@
import mqtt from 'mqtt'
import { log } from './helpers/log.js'
import { getRegisteredTopics } from './registry.js'
import { routeMessage } from './router.js'
const mqttUrl = process.env.MQTT_URL ?? 'mqtt://localhost:1883'
const mqttUrl = process.env.MQTT_URL as string
export const mqttClient = mqtt.connect(mqttUrl)
export const mqttClient = mqtt.connect(mqttUrl, {
clean: true,
queueQoSZero: false,
reconnectPeriod: 1000,
connectTimeout: 10000,
});
mqttClient.on('connect', () => {
console.log('mqtt.connected', { url: mqttUrl })
log('mqtt.connected', { url: mqttUrl })
const topics = getRegisteredTopics()
if (topics.length === 0) {
@@ -21,7 +27,7 @@ mqttClient.on('connect', () => {
console.error('mqtt.subscribe_error', err)
return
}
console.log('mqtt.subscribed', { topics })
log('mqtt.subscribed', { topics })
})
})
@@ -34,5 +40,5 @@ mqttClient.on('error', (err) => {
})
mqttClient.on('reconnect', () => {
console.log('mqtt.reconnect')
log('mqtt.reconnect')
})

View File

@@ -1,18 +1,37 @@
import { topicMatchesFilter } from './helpers/topic-match.js'
import type { TopicHandler } from './types.js'
const handlers = new Map<string, TopicHandler>()
const exactHandlers = new Map<string, TopicHandler>()
const patternHandlers = new Map<string, TopicHandler>()
function isPatternTopic(topic: string): boolean {
return topic.includes('+') || topic.includes('#')
}
export function register(topic: string, handler: TopicHandler): void {
const handlers = isPatternTopic(topic) ? patternHandlers : exactHandlers
if (handlers.has(topic)) {
throw new Error(`Handler already registered for topic: ${topic}`)
}
handlers.set(topic, handler)
}
export function getHandler(topic: string): TopicHandler | undefined {
return handlers.get(topic)
export function resolveHandler(topic: string): TopicHandler | undefined {
const exact = exactHandlers.get(topic)
if (exact) {
return exact
}
for (const [filter, handler] of patternHandlers) {
if (topicMatchesFilter(filter, topic)) {
return handler
}
}
return undefined
}
export function getRegisteredTopics(): string[] {
return [...handlers.keys()]
return [...exactHandlers.keys(), ...patternHandlers.keys()]
}

View File

@@ -1,10 +1,10 @@
import { getHandler } from './registry.js'
import { resolveHandler } from './registry.js'
export async function routeMessage(
topic: string,
payload: Buffer,
): Promise<void> {
const handler = getHandler(topic)
const handler = resolveHandler(topic)
if (!handler) {
console.warn('mqtt.unhandled_topic', { topic })

View File

@@ -1,29 +1,42 @@
import type { IncomingMessage } from 'http'
import { WebSocket, WebSocketServer } from 'ws'
const wsPort = Number(process.env.WS_PORT ?? 9090)
import { log } from '../helpers/log.js'
import { lastTopicSegment } from '../helpers/topic-match.js'
const wsPort = Number(process.env.WS_PORT)
const wss = new WebSocketServer({ port: wsPort, perMessageDeflate: false })
const clients = new Set<WebSocket>()
const clientsByCamera = new Map<string, Set<WebSocket>>()
function getClients(cameraId: string): Set<WebSocket> {
let clients = clientsByCamera.get(cameraId)
if (!clients) {
clients = new Set()
clientsByCamera.set(cameraId, clients)
}
return clients
}
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
clients.add(ws)
console.log('ws.client_connected', {
clients: clients.size,
ip: req.socket.remoteAddress,
})
log(`ws.connection ${req.url}`);
req.socket.setNoDelay(true)
const cameraId = req.url ? lastTopicSegment(req.url) : 'v1';
const clients = getClients(cameraId);
clients.add(ws);
ws.on('close', () => {
clients.delete(ws)
console.log('ws.client_closed', { clients: clients.size })
})
if (clients.size === 0) clientsByCamera.delete(cameraId)
});
})
wss.on('listening', () => {
console.log('ws.server_started', { port: wsPort })
})
export function onEdgeChunk(chunk: Buffer): void {
export function onEdgeChunk(cameraId: string, chunk: Buffer): void {
const clients = clientsByCamera.get(cameraId)
if (!clients) return
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(chunk)

View File

@@ -7,8 +7,7 @@
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": true
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,37 +1,37 @@
services:
mosquitto:
image: eclipse-mosquitto:2
build: ./mosquitto
container_name: mosquitto
ports:
- "1883:1883"
command:
- /bin/sh
- -c
- |
cat > /tmp/mosquitto.conf <<'EOF'
listener 1883
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
log_dest stdout
EOF
exec mosquitto -c /tmp/mosquitto.conf
- "1883:1883" # external MQTT port for receiving data
volumes:
- mosquitto_data:/mosquitto/data
networks:
- default
app:
build: ./app
container_name: mqtt-worker
depends_on:
- mosquitto
ports:
# - "80:80"
- "9090:9090"
expose:
- 80
- 9090
environment:
MQTT_URL: mqtt://mosquitto:1883
HTTP_PORT: "80"
WS_PORT: "9090"
CLOUD_INGEST_URL: ${CLOUD_INGEST_URL:-https://greact.drll.cloud/api/ingest}
HTTP_PORT: 80 # http port for video previewing page
WS_PORT: 9090 # WebSocket port for video streaming
CLOUD_INGEST_URL: ${CLOUD_INGEST_URL}
CLOUD_INGEST_API_KEY: ${CLOUD_INGEST_API_KEY}
DEMO_CLOUD_INGEST_URL: ${DEMO_CLOUD_INGEST_URL}
EDGE5_MODBUS_V2_MAPPING_FILE: ${EDGE5_MODBUS_V2_MAPPING_FILE:-/app/assets/edge5-modbus.json}
networks:
- default
- proxy
volumes:
mosquitto_data:
networks:
proxy:
external: true

3
mosquitto/Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM eclipse-mosquitto:2
COPY config/mosquitto.conf /mosquitto/config/mosquitto.conf

View File

@@ -1,6 +1,8 @@
listener 1883
allow_anonymous true
set_tcp_nodelay true # выключает Nagle на всех сокетах брокера
persistence true
persistence_location /mosquitto/data/

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "mqtt-ingest",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}