Как стать автором
Обновить

Как создать простой LoRa мессенджер: обмен зашифрованными текстовыми сообщениями между устройствами без интернета

Уровень сложностиСредний
Время на прочтение30 мин
Количество просмотров13K

В современном мире IoT, когда связь в отдаленных районах становится все более актуальной, технология LoRa (Long Range) предоставляет нам возможность создать дальнобойный, надежный, энергоэффективный и зашифрованный канал связи без необходимости иметь какую-либо сетевую инфраструктуру.

В этой статье мы рассмотрим, как создать простой LoRa мессенджер с использованием своего протокола обмена и готовых LoRa модулей, работающих в режиме P2P (peer-to-peer) – не идеального, но интересного решения для обмена текстовыми сообщениями в условиях ограниченной инфраструктуры.

Для упрощения и автоматизации процесса обмена сообщениями мы воспользуемся Node-RED. Этот инструмент, помимо реализации основной логики обмена сообщениями, также предоставит графический интерфейс для мессенджера, что сделает процесс более доступным и интуитивно понятным.

Выглядеть будет просто, потому что воспользуемся всем готовым :)

Интерфейс обмена сообщениями
Интерфейс обмена сообщениями

Подготовка

Для успешной реализации задуманного потребуются следующие компоненты для каждого участника:

  1. Готовый LoRa-модуль с поддержкой AT-команд

  2. Преобразователь USB to UART

  3. Компуктер с Node-RED

В качестве готового LoRa модуля можно воспользоваться продукцией от RAK Wireless, например, модулем RAK11720 Breakout Board, который продается по 18$ за штуку. Можно взять вариант подешевле (без BLE) RAK3172 Breakout Board за 15$. Оба базируются на трансивере Semtech SX1262. Заказать можно через Ali. Нужно по экземпляру для каждого участника.

Оба варианта позволяют использовать любую совместимую внешнюю антенну с разъемом RP-SMA Female. Фирменная антенна конечно же идет в комплекте c модулем за такую цену.

На плате также присутствуют все необходимые контакты для подключения модуля к ПК по UART и взаимодействия с ним через AT-команды.

Сам модуль функционирует на фирменной прошивке от RAK Wireless – RUI3, которая предоставляет разработчикам возможность создавать собственное программное обеспечение с использованием RUI3 API, которое легко интегрируется с популярными средами разработки, такими как Arduino и Visual Studio. Подробнее можно ознакомиться в документации.

Выглядит следующим образом:

RAK11720 Breakout Board
RAK11720 Breakout Board

В качестве преобразователя USB to UART, при помощи которого будет происходить общение с LoRa модулем, подойдет любой вариант с Ali.

Варианты USB to UART:

Дешево и сердито USB to UART CP1202:

USB to UART CP1202

Или вариант качества получше, с гальванической развязкой по питанию: USB to UART FT232:

USB to UART FT232

Да и любой другой сгодится, лишь бы питание 3.3V было.

Скачать и установить Node-RED можно с оф.сайта: nodered.org

Реализация

Общая схема решения выглядит следующим образом
Общая схема решения выглядит следующим образом

Подключаем RAK11720 через USB to UART к ПК.

Распиновка RAK11720 есть здесь
Распиновка RAK11720 есть здесь

Запускаем Node-RED и доустанавливаем узлы: node-red-dashboard и node-red-node-serialport:

Как доустановить узлы показано здесь:
Для этого переходим в выпадающее меню (1) и пункт "Управление палитрой" (2)
Для этого переходим в выпадающее меню (1) и пункт "Управление палитрой" (2)
В разделе "Палитра" (1) нужно перейти на вкладку "Установить"(2), где в поле для ввода (3) необходимо ввести название палитр: node-red-dashboard и node-red-node-serialport и нажать на кнопку "Установить".
В разделе "Палитра" (1) нужно перейти на вкладку "Установить"(2), где в поле для ввода (3) необходимо ввести название палитр: node-red-dashboard и node-red-node-serialport и нажать на кнопку "Установить".

Палитра node-red-dashboard позволит создать веб-интерфейс обмена сообщениями и взаимодействия с LoRa модулем.

Палитра node-red-node-serialport позволит общаться с LoRa модулем через AT команды по UART прямо из интерфейса Node-RED.

Логика достаточно проста, если ввели текстовое сообщение, происходит переключение LoRa модуля в режим отправки (по умолчанию модуль постоянно слушает), сообщение кодируется, согласно протоколу обмена и отправляется через AT команду на модуль. После отправки происходит переключение обратно в режим прослушивания сообщений. Итоговый поток будет выглядеть следующим образом:

Итоговый поток в Node-RED для обмена сообщениями по LoRa
Итоговый поток в Node-RED для обмена сообщениями по LoRa

Не буду описывать по шагам, как собрать step by step сие творение, а просто приложу json-файл для импорта.

flows.json
[
    {
        "id": "2f52923ccb63864b",
        "type": "tab",
        "label": "LoRa P2P Messeging",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "bb3960f039b0d5d9",
        "type": "group",
        "z": "2f52923ccb63864b",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "9362510d02f2a3cd",
            "653c14cbd7d04855",
            "411dc5558acb5d69",
            "7c928c5461e79357",
            "3d751b388616d680",
            "d7f18a1a41328fc2",
            "39310643b351cbe9",
            "0ad15eca9748f8c2",
            "2aa5a90b9a47e61a",
            "41c7e933bc23bf15",
            "e226f98ef20c6e3c",
            "f193efb9e856e239",
            "8215f7a39798258c",
            "9f6693be1c896628",
            "796a0c4c7349efdc",
            "d2b59a73f776232a",
            "c9a150394bb8dd8b",
            "229f643b2e4dc980"
        ],
        "x": 34,
        "y": 79,
        "w": 1892,
        "h": 342
    },
    {
        "id": "0c3ab2711ef23cd5",
        "type": "group",
        "z": "2f52923ccb63864b",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "3fcbc4e3b39e37e0",
            "550ac146763bb1cf",
            "ded89b84d1895114",
            "29ee0d0fd91ab5d4",
            "e03995b19d8f6ff1",
            "3a8125696d200c02",
            "31d9cb982c9d1c95",
            "30d506a8ea905666",
            "9f9472d611ecf562"
        ],
        "x": 34,
        "y": 479,
        "w": 1032,
        "h": 202
    },
    {
        "id": "9362510d02f2a3cd",
        "type": "debug",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "3: Кодированные данные",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 940,
        "y": 180,
        "wires": []
    },
    {
        "id": "653c14cbd7d04855",
        "type": "debug",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "1: Сырые данные",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 390,
        "y": 120,
        "wires": []
    },
    {
        "id": "3fcbc4e3b39e37e0",
        "type": "function",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "name": "Декодирование данных",
        "func": "// Установите значения для соответствия символов и их кодировок\nconst encodingTable = {\n    ' ': '00',\n    '.': '01',\n    ',': '02',\n    '!': '03',\n    '?': '04',\n    '-': '05',\n    '(': '06',\n    ')': '07',\n    'а': '08',\n    'б': '09',\n    'в': '0A',\n    'г': '0B',\n    'д': '0C',\n    'е': '0D',\n    'ж': '0E',\n    'з': '0F',\n    'и': '10',\n    'й': '11',\n    'к': '12',\n    'л': '13',\n    'м': '14',\n    'н': '15',\n    'о': '16',\n    'п': '17',\n    'р': '18',\n    'с': '19',\n    'т': '1A',\n    'у': '1B',\n    'ф': '1C',\n    'х': '1D',\n    'ц': '1E',\n    'ч': '1F',\n    'ш': '20',\n    'щ': '21',\n    'ъ': '22',\n    'ы': '23',\n    'ь': '24',\n    'э': '25',\n    'ю': '26',\n    'я': '27',\n    'А': '28',\n    'Б': '29',\n    'В': '2A',\n    'Г': '2B',\n    'Д': '2C',\n    'Е': '2D',\n    'Ё': '2E',\n    'Ж': '2F',\n    'З': '30',\n    'И': '31',\n    'Й': '32',\n    'К': '33',\n    'Л': '34',\n    'М': '35',\n    'Н': '36',\n    'О': '37',\n    'П': '38',\n    'Р': '39',\n    'С': '3A',\n    'Т': '3B',\n    'У': '3C',\n    'Ф': '3D',\n    'Х': '3E',\n    'Ц': '3F',\n    'Ч': '40',\n    'Ш': '41',\n    'Щ': '42',\n    'Ъ': '43',\n    'Ы': '44',\n    'Ь': '45',\n    'Э': '46',\n    'Ю': '47',\n    'Я': '48',\n    ', а': '49',\n    ', б': '4A',\n    ', в': '4B',\n    ', г': '4C',\n    ', д': '4D',\n    ', е': '4E',\n    ', ж': '4F',\n    ', з': '50',\n    ', и': '51',\n    ', й': '52',\n    ', к': '53',\n    ', л': '54',\n    ', м': '55',\n    ', н': '56',\n    ', о': '57',\n    ', п': '58',\n    ', р': '59',\n    ', с': '5A',\n    ', т': '5B',\n    ', у': '5C',\n    ', ф': '5D',\n    ', х': '5E',\n    ', ц': '5F',\n    ', ч': '60',\n    ', ш': '61',\n    ', щ': '62',\n    ', ъ': '63',\n    ', ы': '64',\n    ', ь': '65',\n    ', э': '66',\n    ', ю': '67',\n    ', я': '68',\n    ' а ': '69',\n    ' в ': '6A',\n    ' и ': '6B',\n    ' к ': '6C',\n    ' с ': '6D',\n    ' у ': '6E',\n    ' я ': '6F',\n    'будет': '70',\n    'буду': '71',\n    'бы': '72',\n    'был': '73',\n    'была': '74',\n    'были': '75',\n    'вас': '76',\n    'вот': '77',\n    'все': '78',\n    'всех': '79',\n    'вы': '7A',\n    'где': '7B',\n    'для': '7C',\n    'его': '7D',\n    'ее': '7E',\n    'ей': '7F',\n    'ем': '80',\n    'ему': '81',\n    'если': '82',\n    'есть': '83',\n    'ею': '84',\n    'зачем': '85',\n    'здесь': '86',\n    'из': '87',\n    'или': '88',\n    'их': '89',\n    'как': '8A',\n    'какая': '8B',\n    'какие': '8C',\n    'какое': '8D',\n    'какой': '8E',\n    'когда': '8F',\n    'которая': '90',\n    'которое': '91',\n    'которые': '92',\n    'который': '93',\n    'кто': '94',\n    'меня': '95',\n    'мне': '96',\n    'мог': '97',\n    'могла': '98',\n    'могли': '99',\n    'могу': '9A',\n    'могут': '9B',\n    'мое': '9C',\n    'моего': '9D',\n    'моей': '9E',\n    'может': '9F',\n    'можете': 'A0',\n    'мои': 'A1',\n    'моим': 'A2',\n    'моих': 'A3',\n    'мой': 'A4',\n    'моя': 'A5',\n    'мы': 'A6',\n    'на': 'A7',\n    'наш': 'A8',\n    'наша': 'A9',\n    'наше': 'AA',\n    'наши': 'AB',\n    'не': 'AC',\n    'него': 'AD',\n    'нему': 'AE',\n    'ним': 'AF',\n    'но': 'B0',\n    'он': 'B1',\n    'они': 'B2',\n    'от': 'B3',\n    'отсюда': 'B4',\n    'оттуда': 'B5',\n    'по': 'B6',\n    'потом': 'B7',\n    'почему': 'B8',\n    'свое': 'B9',\n    'свои': 'BA',\n    'свой': 'BB',\n    'своя': 'BC',\n    'сейчас': 'BD',\n    'сказал': 'BE',\n    'сказала': 'BF',\n    'сколько': 'C0',\n    'сюда': 'C1',\n    'такая': 'C2',\n    'также': 'C3',\n    'такие': 'C4',\n    'такое': 'C5',\n    'такой': 'C6',\n    'там': 'C7',\n    'твое': 'C8',\n    'твоего': 'C9',\n    'твоей': 'CA',\n    'твоем': 'CB',\n    'твои': 'CC',\n    'твоих': 'CD',\n    'твой': 'CE',\n    'твоя': 'CF',\n    'тебя': 'D0',\n    'теперь': 'D1',\n    'то': 'D2',\n    'тогда': 'D3',\n    'туда': 'D4',\n    'тут': 'D5',\n    'ты': 'D6',\n    'что': 'D7',\n    'этим': 'D8',\n    'это': 'D9',\n    'этого': 'DA',\n    'этой': 'DB',\n    'этому': 'DC',\n    'этот': 'DD',\n    'хм': 'DE',\n    '1': 'DF',\n    '2': 'E0',\n    '3': 'E1',\n    '4': 'E2',\n    '5': 'E3',\n    '6': 'E4',\n    '7': 'E5',\n    '8': 'E6',\n    '9': 'E7',\n    '0': 'E8',\n    'Привет': 'E9',\n    'Привет,': 'EA',\n    'Хабр': 'EB'\n};\n\n// Обратное преобразование\nfunction decodeString(encodedString) {\n    let decodedString = '';\n\n    for (let i = 0; i < encodedString.length; i += 2) {\n        const code = encodedString.substr(i, 2);\n\n        // Поиск символа в таблице\n        const char = Object.keys(encodingTable).find(key => encodingTable[key] === code);\n\n        // Если символ найден, добавляем его к раскодированной строке\n        if (char) {\n            decodedString += char;\n        } else {\n            // Если символ не найден, оставляем его без изменений\n            decodedString += code;\n        }\n    }\n\n    // Создаем объект сообщения и возвращаем результат\n    const msg = { payload: decodedString };\n    return msg;\n}\n\n// Закодированная строка для обратного преобразования\nconst encodedString = msg.payload;\n\n// Применяем функцию к закодированной строке\nconst resultDecoding = decodeString(encodedString);\n\n// Выводим результат в debug\nreturn resultDecoding;\n",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 590,
        "y": 580,
        "wires": [
            [
                "550ac146763bb1cf",
                "ded89b84d1895114",
                "31d9cb982c9d1c95"
            ]
        ]
    },
    {
        "id": "550ac146763bb1cf",
        "type": "debug",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "name": "6: Декодированные данные",
        "active": false,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 880,
        "y": 520,
        "wires": []
    },
    {
        "id": "411dc5558acb5d69",
        "type": "function",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "Кодирование данных",
        "func": "// Протокол обмена\nconst encodingTable = {\n    ' ': '00',\n    '.': '01',\n    ',': '02',\n    '!': '03',\n    '?': '04',\n    '-': '05',\n    '(': '06',\n    ')': '07',\n    'а': '08',\n    'б': '09',\n    'в': '0A',\n    'г': '0B',\n    'д': '0C',\n    'е': '0D',\n    'ж': '0E',\n    'з': '0F',\n    'и': '10',\n    'й': '11',\n    'к': '12',\n    'л': '13',\n    'м': '14',\n    'н': '15',\n    'о': '16',\n    'п': '17',\n    'р': '18',\n    'с': '19',\n    'т': '1A',\n    'у': '1B',\n    'ф': '1C',\n    'х': '1D',\n    'ц': '1E',\n    'ч': '1F',\n    'ш': '20',\n    'щ': '21',\n    'ъ': '22',\n    'ы': '23',\n    'ь': '24',\n    'э': '25',\n    'ю': '26',\n    'я': '27',\n    'А': '28',\n    'Б': '29',\n    'В': '2A',\n    'Г': '2B',\n    'Д': '2C',\n    'Е': '2D',\n    'Ё': '2E',\n    'Ж': '2F',\n    'З': '30',\n    'И': '31',\n    'Й': '32',\n    'К': '33',\n    'Л': '34',\n    'М': '35',\n    'Н': '36',\n    'О': '37',\n    'П': '38',\n    'Р': '39',\n    'С': '3A',\n    'Т': '3B',\n    'У': '3C',\n    'Ф': '3D',\n    'Х': '3E',\n    'Ц': '3F',\n    'Ч': '40',\n    'Ш': '41',\n    'Щ': '42',\n    'Ъ': '43',\n    'Ы': '44',\n    'Ь': '45',\n    'Э': '46',\n    'Ю': '47',\n    'Я': '48',\n    ', а': '49',\n    ', б': '4A',\n    ', в': '4B',\n    ', г': '4C',\n    ', д': '4D',\n    ', е': '4E',\n    ', ж': '4F',\n    ', з': '50',\n    ', и': '51',\n    ', й': '52',\n    ', к': '53',\n    ', л': '54',\n    ', м': '55',\n    ', н': '56',\n    ', о': '57',\n    ', п': '58',\n    ', р': '59',\n    ', с': '5A',\n    ', т': '5B',\n    ', у': '5C',\n    ', ф': '5D',\n    ', х': '5E',\n    ', ц': '5F',\n    ', ч': '60',\n    ', ш': '61',\n    ', щ': '62',\n    ', ъ': '63',\n    ', ы': '64',\n    ', ь': '65',\n    ', э': '66',\n    ', ю': '67',\n    ', я': '68',\n    ' а ': '69',\n    ' в ': '6A',\n    ' и ': '6B',\n    ' к ': '6C',\n    ' с ': '6D',\n    ' у ': '6E',\n    ' я ': '6F',\n    'будет': '70',\n    'буду': '71',\n    'бы': '72',\n    'был': '73',\n    'была': '74',\n    'были': '75',\n    'вас': '76',\n    'вот': '77',\n    'все': '78',\n    'всех': '79',\n    'вы': '7A',\n    'где': '7B',\n    'для': '7C',\n    'его': '7D',\n    'ее': '7E',\n    'ей': '7F',\n    'ем': '80',\n    'ему': '81',\n    'если': '82',\n    'есть': '83',\n    'ею': '84',\n    'зачем': '85',\n    'здесь': '86',\n    'из': '87',\n    'или': '88',\n    'их': '89',\n    'как': '8A',\n    'какая': '8B',\n    'какие': '8C',\n    'какое': '8D',\n    'какой': '8E',\n    'когда': '8F',\n    'которая': '90',\n    'которое': '91',\n    'которые': '92',\n    'который': '93',\n    'кто': '94',\n    'меня': '95',\n    'мне': '96',\n    'мог': '97',\n    'могла': '98',\n    'могли': '99',\n    'могу': '9A',\n    'могут': '9B',\n    'мое': '9C',\n    'моего': '9D',\n    'моей': '9E',\n    'может': '9F',\n    'можете': 'A0',\n    'мои': 'A1',\n    'моим': 'A2',\n    'моих': 'A3',\n    'мой': 'A4',\n    'моя': 'A5',\n    'мы': 'A6',\n    'на': 'A7',\n    'наш': 'A8',\n    'наша': 'A9',\n    'наше': 'AA',\n    'наши': 'AB',\n    'не': 'AC',\n    'него': 'AD',\n    'нему': 'AE',\n    'ним': 'AF',\n    'но': 'B0',\n    'он': 'B1',\n    'они': 'B2',\n    'от': 'B3',\n    'отсюда': 'B4',\n    'оттуда': 'B5',\n    'по': 'B6',\n    'потом': 'B7',\n    'почему': 'B8',\n    'свое': 'B9',\n    'свои': 'BA',\n    'свой': 'BB',\n    'своя': 'BC',\n    'сейчас': 'BD',\n    'сказал': 'BE',\n    'сказала': 'BF',\n    'сколько': 'C0',\n    'сюда': 'C1',\n    'такая': 'C2',\n    'также': 'C3',\n    'такие': 'C4',\n    'такое': 'C5',\n    'такой': 'C6',\n    'там': 'C7',\n    'твое': 'C8',\n    'твоего': 'C9',\n    'твоей': 'CA',\n    'твоем': 'CB',\n    'твои': 'CC',\n    'твоих': 'CD',\n    'твой': 'CE',\n    'твоя': 'CF',\n    'тебя': 'D0',\n    'теперь': 'D1',\n    'то': 'D2',\n    'тогда': 'D3',\n    'туда': 'D4',\n    'тут': 'D5',\n    'ты': 'D6',\n    'что': 'D7',\n    'этим': 'D8',\n    'это': 'D9',\n    'этого': 'DA',\n    'этой': 'DB',\n    'этому': 'DC',\n    'этот': 'DD',\n    'хм': 'DE',\n    '1': 'DF',\n    '2': 'E0',\n    '3': 'E1',\n    '4': 'E2',\n    '5': 'E3',\n    '6': 'E4',\n    '7': 'E5',\n    '8': 'E6',\n    '9': 'E7',\n    '0': 'E8',\n    'Привет': 'E9',\n    'Привет,': 'EA',\n    'Хабр': 'EB'\n};\n\n// Функция преобразования строки в кодировку\nfunction encodeString(inputString) {\n    const maxBytes = 51;\n    let encodedStrings = [];\n\n    // Если строка больше 51 байта, разбиваем на посылки\n    if (Buffer.from(inputString, 'utf-8').length > maxBytes) {\n        let currentChunk = '';\n        let currentChunkBytes = 0;\n\n        for (let i = 0; i < inputString.length; i++) {\n            const char = inputString[i];\n            const charBytes = Buffer.from(char, 'utf-8').length;\n\n            // Проверяем, можно ли добавить символ к текущей посылке\n            if (currentChunkBytes + charBytes <= maxBytes) {\n                currentChunk += char;\n                currentChunkBytes += charBytes;\n            } else {\n                // Если символ не помещается, добавляем текущую посылку в результат\n                encodedStrings.push(encodeChunk(currentChunk));\n\n                // Начинаем новую посылку\n                currentChunk = char;\n                currentChunkBytes = charBytes;\n            }\n        }\n\n        // Добавляем последнюю посылку в результат\n        if (currentChunk !== '') {\n            encodedStrings.push(encodeChunk(currentChunk));\n        }\n    } else {\n        // Строка помещается в одну посылку\n        encodedStrings.push(encodeChunk(inputString));\n    }\n\n    // Возвращаем генератор, чтобы поочередно возвращать строки с задержкой\n    let index = 0;\n    const sendNext = () => {\n        if (index < encodedStrings.length) {\n            const value = encodedStrings[index++];\n            setTimeout(() => {\n                node.send({ payload: value });\n                sendNext();\n            }, 2000); // Задержка в 2 секунд\n        }\n    };\n\n    sendNext();\n}\n\n// Вспомогательная функция для кодирования части строки\nfunction encodeChunk(chunk) {\n    let encodedChunk = '';\n\n    // Разбиваем часть строки на слова\n    const words = chunk.split(' ');\n\n    // Кодируем каждое слово отдельно\n    for (let i = 0; i < words.length; i++) {\n        const word = words[i];\n\n        // Проверяем, является ли слово словом в словаре\n        if (encodingTable.hasOwnProperty(word)) {\n            encodedChunk += encodingTable[word];\n        } else {\n            // Кодируем каждый символ слова отдельно\n            for (let j = 0; j < word.length; j++) {\n                const char = word[j];\n\n                // Проверяем, есть ли символ в таблице\n                if (encodingTable.hasOwnProperty(char)) {\n                    encodedChunk += encodingTable[char];\n                } else {\n                    // Если символ отсутствует в таблице, оставляем его без изменений\n                    encodedChunk += char;\n                }\n            }\n        }\n\n        // Добавляем пробел между словами\n        if (i < words.length - 1) {\n            encodedChunk += encodingTable[' '];\n        }\n    }\n\n    return encodedChunk;\n}\n\n// Применяем функцию к входной строке\nconst inputString = msg.payload;\nencodeString(inputString);",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 680,
        "y": 240,
        "wires": [
            [
                "3d751b388616d680",
                "d7f18a1a41328fc2",
                "9362510d02f2a3cd"
            ]
        ]
    },
    {
        "id": "7c928c5461e79357",
        "type": "ui_text_input",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "",
        "label": "Данные для отправки",
        "tooltip": "",
        "group": "afb27fbd0a803a11",
        "order": 1,
        "width": 0,
        "height": 0,
        "passthru": true,
        "mode": "text",
        "delay": "1000",
        "topic": "topic",
        "sendOnBlur": true,
        "className": "",
        "topicType": "msg",
        "x": 160,
        "y": 240,
        "wires": [
            [
                "653c14cbd7d04855",
                "e226f98ef20c6e3c",
                "8215f7a39798258c"
            ]
        ]
    },
    {
        "id": "ded89b84d1895114",
        "type": "ui_text",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "group": "afb27fbd0a803a11",
        "order": 3,
        "width": 0,
        "height": 0,
        "name": "",
        "label": "Получено сообщение:",
        "format": "{{msg.payload}}",
        "layout": "row-left",
        "className": "",
        "style": false,
        "font": "",
        "fontSize": "",
        "color": "#000000",
        "x": 870,
        "y": 580,
        "wires": []
    },
    {
        "id": "3d751b388616d680",
        "type": "ui_text",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "group": "afb27fbd0a803a11",
        "order": 2,
        "width": 0,
        "height": 0,
        "name": "",
        "label": "Кодированное сообщение:",
        "format": "{{msg.payload}}",
        "layout": "row-left",
        "className": "",
        "style": false,
        "font": "",
        "fontSize": "",
        "color": "#000000",
        "x": 940,
        "y": 300,
        "wires": []
    },
    {
        "id": "d7f18a1a41328fc2",
        "type": "function",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "Формирование AT команды",
        "func": "// Получаем входные данные из сообщения msg.payload\nvar inputString = msg.payload;\n\n// Формируем строку в требуемом формате\nvar outputString = 'AT+PSEND=' + inputString;\n\n// Создаем новый объект сообщения и устанавливаем в него преобразованную строку\nmsg.payload = outputString;\n\n// Возвращаем объект сообщения\nreturn msg;\n",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 940,
        "y": 240,
        "wires": [
            [
                "39310643b351cbe9",
                "0ad15eca9748f8c2",
                "9f6693be1c896628"
            ]
        ]
    },
    {
        "id": "39310643b351cbe9",
        "type": "serial out",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "",
        "serial": "baa0d15198c00a18",
        "x": 1820,
        "y": 240,
        "wires": []
    },
    {
        "id": "29ee0d0fd91ab5d4",
        "type": "serial in",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "name": "",
        "serial": "baa0d15198c00a18",
        "x": 130,
        "y": 580,
        "wires": [
            [
                "e03995b19d8f6ff1"
            ]
        ]
    },
    {
        "id": "0ad15eca9748f8c2",
        "type": "debug",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "4: Отправка данных на UART",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 1250,
        "y": 180,
        "wires": []
    },
    {
        "id": "e03995b19d8f6ff1",
        "type": "function",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "name": "Фильтруем Payload",
        "func": "// Получаем входные данные из сообщения msg.payload\nvar inputString = msg.payload;\n\n// Разбиваем строку по символу \":\" и выбираем последний элемент массива\nvar parts = inputString.split(':');\nvar extractedValue = parts[parts.length - 1];\n\n// Создаем новый объект сообщения и устанавливаем в него извлеченное значение\nmsg.payload = extractedValue;\n\n// Возвращаем объект сообщения\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 340,
        "y": 580,
        "wires": [
            [
                "3fcbc4e3b39e37e0"
            ]
        ]
    },
    {
        "id": "2aa5a90b9a47e61a",
        "type": "ui_button",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "",
        "group": "afb27fbd0a803a11",
        "order": 4,
        "width": 0,
        "height": 0,
        "passthru": false,
        "label": "Режим P2P",
        "tooltip": "",
        "color": "",
        "bgcolor": "",
        "className": "",
        "icon": "",
        "payload": "AT+NWM=0",
        "payloadType": "str",
        "topic": "topic",
        "topicType": "msg",
        "x": 1590,
        "y": 380,
        "wires": [
            [
                "39310643b351cbe9"
            ]
        ]
    },
    {
        "id": "41c7e933bc23bf15",
        "type": "ui_button",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "",
        "group": "afb27fbd0a803a11",
        "order": 7,
        "width": 0,
        "height": 0,
        "passthru": false,
        "label": "Режим LoRaWAN",
        "tooltip": "",
        "color": "",
        "bgcolor": "",
        "className": "",
        "icon": "",
        "payload": "AT+NWM=1",
        "payloadType": "str",
        "topic": "topic",
        "topicType": "msg",
        "x": 1570,
        "y": 340,
        "wires": [
            [
                "39310643b351cbe9"
            ]
        ]
    },
    {
        "id": "e226f98ef20c6e3c",
        "type": "ui_button",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "",
        "group": "afb27fbd0a803a11",
        "order": 5,
        "width": 0,
        "height": 0,
        "passthru": true,
        "label": "Передача сообщений ВКЛ",
        "tooltip": "",
        "color": "",
        "bgcolor": "",
        "className": "",
        "icon": "",
        "payload": "AT+PRECV=0",
        "payloadType": "str",
        "topic": "topic",
        "topicType": "msg",
        "x": 420,
        "y": 180,
        "wires": [
            [
                "d2b59a73f776232a",
                "229f643b2e4dc980"
            ]
        ]
    },
    {
        "id": "f193efb9e856e239",
        "type": "ui_button",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "",
        "group": "afb27fbd0a803a11",
        "order": 6,
        "width": 0,
        "height": 0,
        "passthru": true,
        "label": "Прием сообщений ВКЛ",
        "tooltip": "",
        "color": "",
        "bgcolor": "",
        "className": "",
        "icon": "",
        "payload": "AT+PRECV=65534",
        "payloadType": "str",
        "topic": "topic",
        "topicType": "msg",
        "x": 1550,
        "y": 300,
        "wires": [
            [
                "39310643b351cbe9"
            ]
        ]
    },
    {
        "id": "8215f7a39798258c",
        "type": "delay",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "",
        "pauseType": "delay",
        "timeout": "250",
        "timeoutUnits": "milliseconds",
        "rate": "1",
        "nbRateUnits": "1",
        "rateUnits": "second",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": false,
        "allowrate": false,
        "outputs": 1,
        "x": 390,
        "y": 240,
        "wires": [
            [
                "411dc5558acb5d69"
            ]
        ]
    },
    {
        "id": "9f6693be1c896628",
        "type": "function",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "Задержка на Прием ВКЛ",
        "func": "// Получаем контекст текущего узла\nvar context = flow.get('sessionContext') || {};\n\n// Инициализируем массив для хранения сообщений текущей сессии\ncontext.sessionMessages = context.sessionMessages || [];\n\n// Добавляем новые сообщения в массив\ncontext.sessionMessages = context.sessionMessages.concat(msg.payload);\n\n// Устанавливаем таймер на 4 секунд для завершения сессии\nif (context.sessionTimer) {\n    clearTimeout(context.sessionTimer);\n}\n\ncontext.sessionTimer = setTimeout(function () {\n    // Когда таймер срабатывает, находим последнее сообщение\n    var lastMessage = context.sessionMessages[context.sessionMessages.length - 1];\n\n    // Очищаем контекст для следующей сессии\n    context.sessionMessages = [];\n    context.sessionTimer = null;\n\n    // Возвращаем последнее сообщение\n    node.send({ payload: lastMessage });\n}, 2500); // 2 секунд таймер\n\n// Если есть еще время до конца сессии, сохраняем контекст\nflow.set('sessionContext', context);\n\n// Возвращаем null или что-то еще, чтобы показать, что обработка не завершена\nreturn null;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 1240,
        "y": 300,
        "wires": [
            [
                "f193efb9e856e239",
                "796a0c4c7349efdc"
            ]
        ]
    },
    {
        "id": "3a8125696d200c02",
        "type": "ui_template",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "group": "afb27fbd0a803a11",
        "name": "History",
        "order": 9,
        "width": "10",
        "height": "5",
        "format": "<div ng-bind-html=\"msg.payload\"></div>",
        "storeOutMessages": true,
        "fwdInMessages": true,
        "resendOnRefresh": true,
        "templateScope": "local",
        "className": "",
        "x": 980,
        "y": 640,
        "wires": [
            []
        ]
    },
    {
        "id": "31d9cb982c9d1c95",
        "type": "function",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "name": "History",
        "func": "// Проверяем, если кнопка \"clearHistory\" была нажата\nif (msg.payload === ' ') {\n    // Очищаем историю\n    context.history = [];\n}\n\n// Добавление нового сообщения в историю\ncontext.history = context.history || [];\ncontext.history.push(msg.payload);\n\n// Ограничение длины истории (если нужно)\nvar maxLength = 25; // Максимальная длина истории\nif (context.history.length > maxLength) {\n    context.history.shift(); // Удаляем самое старое сообщение\n}\n\n// Отправка истории на вход ui_template\nreturn { payload: context.history.join('<br>') };",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 820,
        "y": 640,
        "wires": [
            [
                "3a8125696d200c02"
            ]
        ]
    },
    {
        "id": "30d506a8ea905666",
        "type": "ui_button",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "name": "",
        "group": "afb27fbd0a803a11",
        "order": 8,
        "width": 0,
        "height": 0,
        "passthru": false,
        "label": "СlearHistory",
        "tooltip": "",
        "color": "",
        "bgcolor": "",
        "className": "",
        "icon": "",
        "payload": " ",
        "payloadType": "str",
        "topic": "topic",
        "topicType": "msg",
        "x": 630,
        "y": 640,
        "wires": [
            [
                "31d9cb982c9d1c95"
            ]
        ]
    },
    {
        "id": "796a0c4c7349efdc",
        "type": "debug",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "5: Переключене Прием ВКЛ",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 1570,
        "y": 180,
        "wires": []
    },
    {
        "id": "d2b59a73f776232a",
        "type": "debug",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "2: Переключене Передача ВКЛ",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 720,
        "y": 120,
        "wires": []
    },
    {
        "id": "c9a150394bb8dd8b",
        "type": "comment",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "Отправка данных",
        "info": "",
        "x": 150,
        "y": 120,
        "wires": []
    },
    {
        "id": "9f9472d611ecf562",
        "type": "comment",
        "z": "2f52923ccb63864b",
        "g": "0c3ab2711ef23cd5",
        "name": "Прием данных",
        "info": "",
        "x": 140,
        "y": 520,
        "wires": []
    },
    {
        "id": "229f643b2e4dc980",
        "type": "serial out",
        "z": "2f52923ccb63864b",
        "g": "bb3960f039b0d5d9",
        "name": "",
        "serial": "baa0d15198c00a18",
        "x": 660,
        "y": 180,
        "wires": []
    },
    {
        "id": "afb27fbd0a803a11",
        "type": "ui_group",
        "name": "Участник 1",
        "tab": "af8825e119b68714",
        "order": 1,
        "disp": true,
        "width": "11",
        "collapse": false,
        "className": ""
    },
    {
        "id": "baa0d15198c00a18",
        "type": "serial-port",
        "name": "",
        "serialport": "/dev/ttyUSB3",
        "serialbaud": "115200",
        "databits": "8",
        "parity": "none",
        "stopbits": "1",
        "waitfor": "",
        "dtr": "none",
        "rts": "none",
        "cts": "none",
        "dsr": "none",
        "newline": "500",
        "bin": "false",
        "out": "interbyte",
        "addchar": "\\r\\n",
        "responsetimeout": "2000"
    },
    {
        "id": "af8825e119b68714",
        "type": "ui_tab",
        "name": "Обмен сообщениями 1",
        "icon": "dashboard",
        "order": 1,
        "disabled": false,
        "hidden": false
    }
]

А вот что и зачем нужно и как работает - в продолжении ниже.

Перед тем, как воспользоваться готовым потоком, необходимо не забыть изменить конфигурацию COM порта узла serial (указать тот, который определится в вашей системе), а также настроить LoRa модули на параметры P2P передачи (полное описание команд есть в документации).

AT+LPM=0 // Выключим режим сна (энергосбережения) для модуля
AT+PFREQ=864100000 // Задаем частоту, можно и 433, лишь бы антенна была подходящая
AT+PSF=12 // Задаем Spreading Factor (SF) (12 - самый дальнобойний)
AT+PBW=0 // Задаем ширину канала в 125 kHz
AT+PCR=0 // Задаем CodeRate 4/5
AT+PPL=8 // Задаем длину преамбулы 8
AT+PTP=20 // Задаем мощность 20dBm
AT+ENCRY=1 // Включаем шифрование
AT+ENCKEY=01020304050607080102030405060708 // Задаем ключ шифрования раз
AT+PKEY=0011223344556677 // Задаем ключ шифрования два
AT+P2P=? // Выводит все настройки P2P режима
AT+NWM=0 // Включаем режим P2P

Отправить команды можно через узел inject с выходом на serial порт. На 4 изображении под спойлером показано, как настроить COM порт в Node-RED, для работы с LoRa модулем.

Пример отправки AT команды на COM-порт в Node-RED:
Собираем поток: inject -> serial in / serial out -> debug. При нажатии на узел inject произойдет отправка AT команды на указанный COM-порт через узел serial in. А через serial out и debug можно увидеть ответ модуля.
Собираем поток: inject -> serial in / serial out -> debug. При нажатии на узел inject произойдет отправка AT команды на указанный COM-порт через узел serial in. А через serial out и debug можно увидеть ответ модуля.
 (Настройки узла inject
(Настройки узла inject
Настройки узла serial
Настройки узла serial
Конфигурация узла serial
Конфигурация узла serial

Как работает

Пронумеруем узлы в потоке и веб-панель, чтобы было удобнее разобраться.

Итоговый поток, пронумерованный для разбора
Итоговый поток, пронумерованный для разбора
Веб-панель, пронумерованная для разбора
Веб-панель, пронумерованная для разбора
  1. Часть 1. Отправка данных. Для того, чтобы получить данные для отправки, их нужно ввести в соответствующей форме веб-панели. Узел №1 работает по принципу: как только сняли выделение с поля, через секунду произойдет отправка введенного значения на выход ноды. Значение отправится на узлы №2,3,6. На выходе будет введенная текстовая строка, например "Привет, Хабр 1!".

  2. К выходу узла №1 подключен узел №2 для отладки и отображения сформированного сообщения..

  3. К выходу узла №1 подключен узел №3, задача которого сформировать команду на перевод LoRa модуля с режима прослушивания в режим передачи данных для узла №4,5. На выходе будет строка с содержимым "AT+PRECV=0".

  4. К выходу узла №3 подключен узел №4 для отладки, чтобы увидеть, какая AT команда отправляется на выходе узла №3.

  5. К выходу узла №3 подключен узел №5, для отправки данных на serial-порт LoRa модуля. Сюда поступит команда из шага 3 "AT+PRECV=0".

  6. К выходу узла №1 подключен узел №6 для введения небольшой задержки передаваемых данных на узел №7, так как на выполнение шагов 3 и 5 нужно время для передачи команды по UART + время отработки команды LoRa модулем, поэтому введем небольшую задержку, чтобы не было нарушений в логике.

  7. К выходу узла №6 подключен узел функции №7 "Кодирование данных", задача которого закодировать данные, согласно протоколу обмена для той строки, которая поступила к нему на вход, и выдать результат узлу №8,9,11. Код функции приведен ниже под спойлером. Функция хороша тем, что позволяет сформировать свой протокол обмена для используемого в передаче набора символов. Я специально не использовал что-то стандартное, так как в таком варианте больше гибкости. Символ или слово кодируется 1 байтом. Слова определяются разделением через пробел. По сути свой протокол обмена это еще один небольшой уровень защиты передаваемых данных. Важно, чтобы массив со словарем был идентичен у отправителя и получателя (Узла №7 и Узла №19), об этом нужно договориться заранее. Например, из "Привет, Хабр 1!" получится EA00EB00DF03.

  8. К выходу узла №7 подключен узел №8 для отладки, чтобы увидеть, какая посылка в виде hex-строки сформировалась по итогу работы функции.

  9. К выходу узла №7 подключен узел функции №9 "Формирование AT команды", задача которого сформировать AT команду на отправку данных, подготовленных на шаге 7, на узлы 10,12,17. На выходе будет команда AT+PSEND=EA00EB00DF03.

  10. К выходу узла №9 подключен узел №10 для отладки, чтобы увидеть, какая посылка в виде AT-команды сформировалась по итогу работы функции №9.

  11. К выходу узла №7 подключен узел №11, чтобы вывести на веб-панель посылку в виде hex-строки, которая сформировалась по итогу работы функции узла №7, например EA00EB00DF03.

  12. К выходу узла №9 подключен узел функции №12 "Задержка на Прием ВКЛ", задача которого дождаться последнее сообщение на своем входе и подать сигнал на узлы №13,14, чтобы после отправки последнего сообщения перевести LoRa модуль в режим прослушивания. Так как для работы используется SF12, максимальная длина посылки составляет 51 байт. Если на входе узла №7 поступает текстовая строка, которая по результатам кодирования получается длиннее 51 байта, то узел №7 разбивает посылку на части с длиной не более 51 байта. И выводит каждую посылку с небольшой настраиваемой задержкой (2 секунды) одну за другой. Так вот, задача узла №12 понять, что череда посылок закончилась и можно переходить в режим прослушивания. Логика определения достаточно проста: если с момента получения на входе функции прошло более 2,5 секунд и не было принято еще каких-либо сообщений, то сообщения считается последним, и можно подавать импульс на выход функции. Если же на вход функции поступало какое-то значение, то таймер сбрасывается.

  13. К выходу узла №12 подключен узел №13 для отладки, чтобы увидеть момент формирования посылки на переключение LoRa модуля в режим прослушивания вместо режима передачи.

  14. К выходу узла №12 подключен узел №14, чтобы сформировать посылку на переключение LoRa модуля в режим прослушивания вместо режима передачи для узла №17. Узел №14 работает просто, если на вход поступает сообщение, он нажимает на кнопку. Таким образом, переключение режимов между прослушиванием и передачей можно выполнять в ручном режиме, при помощи кнопок веб-панели, так и автоматически, в процессе работа потока. На выходе будет строка с содержимым "AT+PRECV=65534".

  15. Узел №15 представляет собой просто кнопку на веб-панели, которая не участвует в логике работы потока, но позволяет по нажатию включить режим LoRaWAN на LoRa модуле. На выходе будет строка с содержимым "AT+NWM=1". Переключение может понадобится для настройки модуля через AT команды.

  16. Узел №16 тоже представляет собой просто кнопку на веб-панели, которая не участвует в логике работы потока, но позволяет по нажатию включить режим P2P на LoRa модуле. В целом, все необходимые AT команды можно вывести в качестве кнопок на веб-панель. На выходе будет строка с содержимым "AT+NWM=0".

  17. К входу узла №17 подключены узлы №9,14,15,16, для отправки AT-команд по UART на LoRa модуль.

  18. Часть 2. Прием данных. LoRa модуль нашего собеседника (как и наш) также подключен к Node-RED через узел serial out и имеет идентичную конфигурацию. Любое сообщение, которое прилетит на LoRa модуль будет выведено на выход узла №18. Например, "EVT:RXP2P:-19:5:EA00EB00DF03".

  19. К выходу узла №18 подключен узел функции №19 "Фильтруем Payload", задача которого из полученного сообщения "EVT:RXP2P:-19:5:EA00EB00DF03" оставить только полезную нагрузку: "EA00EB00DF03" и передать этот результат на узел №20.

  20. К выходу узла №19 подключен узел функции №20 "Декодирование данных", задача которого выполнить обратное узлу №7 преобразование: перевести hex байты в сообщение, согласно протоколу обмена, и передать этот результат на узел №21, 22, 23. В нашем примере на выходе будет "Привет, Хабр 1!"

  21. К выходу узла №20 подключен узел №21 для отладки, чтобы увидеть, как выглядит декодированная посылка.

  22. К выходу узла №20 подключен узел №22, чтобы вывести на веб-панель посылку в виде текстовой строки, которая сформировалась по итогу работы функции узла №20, например "Привет, Хабр 1!".

  23. К выходу узла №20 подключен узел функции №23 "History", задача которого сохранять историю сообщений, которые были получены этой функцией, в память переменной. Т.е. полученные данные не пишутся на диск, а только в оперативную память.

  24. К выходу узла №23 подключен узел №24, чтобы вывести на веб-панель историю полученных сообщений (текстовых строк, которые поступают на вход).

  25. К входу узла №23 подключен узел №25, чтобы по нажатию на кнопку "ClearHistory" веб-панели, можно было очищать переменную, в которой хранится история сообщений.

Более сжатое описание

Часть 1: Отправка данных

  • Узел №1 (Ввод данных):

    • Получает данные для отправки из веб-панели.

    • По окончании ввода данных, через секунду отправляет введенное значение на выход узла №1.

    • Пример: "Привет, Хабр 1!"

  • Узел №2 (Отладка):

    • Подключен к выходу узла №1 для отображения сформированного сообщения.

  • Узел №3 (Формирование команды):

    • Формирует команду на перевод LoRa модуля в режим передачи данных для узлов №4,5.

    • Пример: "AT+PRECV=0".

  • Узел №4 (Отладка команды):

    • Подключен для отображения отправляемой AT-команды.

  • Узел №5 (Отправка команды):

    • Отправляет сформированную команду на serial-порт LoRa модуля.

  • Узел №6 (Задержка):

    • Вводит небольшую задержку перед отправкой данных на узел №7.

  • Узел №7 (Кодирование данных):

    • Кодирует данные с использованием пользовательского протокола обмена.

    • Пример: "EA00EB00DF03".

  • Узел №8 (Отладка кодирования):

    • Отображает сформированную hex-строку после кодирования.

  • Узел №9 (Формирование AT команды):

    • Формирует AT-команду для отправки закодированных данных.

    • Пример: "AT+PSEND=EA00EB00DF03".

  • Узел №10 (Отладка AT-команды):

    • Отображает сформированную AT-команду.

  • Узел №11 (Отображение hex-строки):

    • Отображает hex-строку сформированную узлом №7.

  • Узел №12 (Задержка на Прием ВКЛ):

    • Дожидается последнего сообщения перед переключением LoRa модуля в режим прослушивания.

  • Узел №13 (Отладка переключения режима):

    • Отображает момент переключения LoRa модуля в режим прослушивания.

  • Узел №14 (Формирование AT команды для переключения режима):

    • Формирует AT-команду для переключения режима.

    • Пример: "AT+PRECV=65534".

  • Узел №15 (Включение режима LoRaWAN):

    • Позволяет включить режим LoRaWAN на LoRa модуле.

  • Узел №16 (Включение режима P2P):

    • Позволяет включить режим P2P на LoRa модуле.

  • Узел №17 (Отправка AT-команд):

    • Подключен к узлам для отправки AT-команд по UART на LoRa модуль.

Часть 2: Прием данных

  • Узел №18 (Прием данных):

    • Получает сообщения от LoRa модуля.

  • Узел №19 (Фильтрация Payload):

    • Оставляет только полезную нагрузку из полученного сообщения.

  • Узел №20 (Декодирование данных):

    • Декодирует данные обратно, согласно протоколу обмена.

  • Узел №21 (Отладка декодирования):

    • Отображает декодированное сообщение.

  • Узел №22 (Отображение декодированного сообщения):

    • Выводит декодированное сообщение на веб-панель.

  • Узел №23 (История сообщений):

    • Сохраняет историю полученных сообщений.

  • Узел №24 (Отображение истории сообщений):

    • Выводит историю сообщений на веб-панель

  • Узел №25 (Очистка истории сообщений):

    • Очищает историю сообщений на веб-панели

Код узла функции "Кодирование данных"
// Протокол обмена
const encodingTable = {
    ' ': '00',
    '.': '01',
    ',': '02',
    '!': '03',
    '?': '04',
    '-': '05',
    '(': '06',
    ')': '07',
    'а': '08',
    'б': '09',
    'в': '0A',
    'г': '0B',
    'д': '0C',
    'е': '0D',
    'ж': '0E',
    'з': '0F',
    'и': '10',
    'й': '11',
    'к': '12',
    'л': '13',
    'м': '14',
    'н': '15',
    'о': '16',
    'п': '17',
    'р': '18',
    'с': '19',
    'т': '1A',
    'у': '1B',
    'ф': '1C',
    'х': '1D',
    'ц': '1E',
    'ч': '1F',
    'ш': '20',
    'щ': '21',
    'ъ': '22',
    'ы': '23',
    'ь': '24',
    'э': '25',
    'ю': '26',
    'я': '27',
    'А': '28',
    'Б': '29',
    'В': '2A',
    'Г': '2B',
    'Д': '2C',
    'Е': '2D',
    'Ё': '2E',
    'Ж': '2F',
    'З': '30',
    'И': '31',
    'Й': '32',
    'К': '33',
    'Л': '34',
    'М': '35',
    'Н': '36',
    'О': '37',
    'П': '38',
    'Р': '39',
    'С': '3A',
    'Т': '3B',
    'У': '3C',
    'Ф': '3D',
    'Х': '3E',
    'Ц': '3F',
    'Ч': '40',
    'Ш': '41',
    'Щ': '42',
    'Ъ': '43',
    'Ы': '44',
    'Ь': '45',
    'Э': '46',
    'Ю': '47',
    'Я': '48',
    ', а': '49',
    ', б': '4A',
    ', в': '4B',
    ', г': '4C',
    ', д': '4D',
    ', е': '4E',
    ', ж': '4F',
    ', з': '50',
    ', и': '51',
    ', й': '52',
    ', к': '53',
    ', л': '54',
    ', м': '55',
    ', н': '56',
    ', о': '57',
    ', п': '58',
    ', р': '59',
    ', с': '5A',
    ', т': '5B',
    ', у': '5C',
    ', ф': '5D',
    ', х': '5E',
    ', ц': '5F',
    ', ч': '60',
    ', ш': '61',
    ', щ': '62',
    ', ъ': '63',
    ', ы': '64',
    ', ь': '65',
    ', э': '66',
    ', ю': '67',
    ', я': '68',
    ' а ': '69',
    ' в ': '6A',
    ' и ': '6B',
    ' к ': '6C',
    ' с ': '6D',
    ' у ': '6E',
    ' я ': '6F',
    'будет': '70',
    'буду': '71',
    'бы': '72',
    'был': '73',
    'была': '74',
    'были': '75',
    'вас': '76',
    'вот': '77',
    'все': '78',
    'всех': '79',
    'вы': '7A',
    'где': '7B',
    'для': '7C',
    'его': '7D',
    'ее': '7E',
    'ей': '7F',
    'ем': '80',
    'ему': '81',
    'если': '82',
    'есть': '83',
    'ею': '84',
    'зачем': '85',
    'здесь': '86',
    'из': '87',
    'или': '88',
    'их': '89',
    'как': '8A',
    'какая': '8B',
    'какие': '8C',
    'какое': '8D',
    'какой': '8E',
    'когда': '8F',
    'которая': '90',
    'которое': '91',
    'которые': '92',
    'который': '93',
    'кто': '94',
    'меня': '95',
    'мне': '96',
    'мог': '97',
    'могла': '98',
    'могли': '99',
    'могу': '9A',
    'могут': '9B',
    'мое': '9C',
    'моего': '9D',
    'моей': '9E',
    'может': '9F',
    'можете': 'A0',
    'мои': 'A1',
    'моим': 'A2',
    'моих': 'A3',
    'мой': 'A4',
    'моя': 'A5',
    'мы': 'A6',
    'на': 'A7',
    'наш': 'A8',
    'наша': 'A9',
    'наше': 'AA',
    'наши': 'AB',
    'не': 'AC',
    'него': 'AD',
    'нему': 'AE',
    'ним': 'AF',
    'но': 'B0',
    'он': 'B1',
    'они': 'B2',
    'от': 'B3',
    'отсюда': 'B4',
    'оттуда': 'B5',
    'по': 'B6',
    'потом': 'B7',
    'почему': 'B8',
    'свое': 'B9',
    'свои': 'BA',
    'свой': 'BB',
    'своя': 'BC',
    'сейчас': 'BD',
    'сказал': 'BE',
    'сказала': 'BF',
    'сколько': 'C0',
    'сюда': 'C1',
    'такая': 'C2',
    'также': 'C3',
    'такие': 'C4',
    'такое': 'C5',
    'такой': 'C6',
    'там': 'C7',
    'твое': 'C8',
    'твоего': 'C9',
    'твоей': 'CA',
    'твоем': 'CB',
    'твои': 'CC',
    'твоих': 'CD',
    'твой': 'CE',
    'твоя': 'CF',
    'тебя': 'D0',
    'теперь': 'D1',
    'то': 'D2',
    'тогда': 'D3',
    'туда': 'D4',
    'тут': 'D5',
    'ты': 'D6',
    'что': 'D7',
    'этим': 'D8',
    'это': 'D9',
    'этого': 'DA',
    'этой': 'DB',
    'этому': 'DC',
    'этот': 'DD',
    'хм': 'DE',
    '1': 'DF',
    '2': 'E0',
    '3': 'E1',
    '4': 'E2',
    '5': 'E3',
    '6': 'E4',
    '7': 'E5',
    '8': 'E6',
    '9': 'E7',
    '0': 'E8',
    'Привет': 'E9',
    'Привет,': 'EA',
    'Хабр': 'EB'
};

// Функция преобразования строки в кодировку
function encodeString(inputString) {
    const maxBytes = 51;
    let encodedStrings = [];

    // Если строка больше 51 байта, разбиваем на посылки
    if (Buffer.from(inputString, 'utf-8').length > maxBytes) {
        let currentChunk = '';
        let currentChunkBytes = 0;

        for (let i = 0; i < inputString.length; i++) {
            const char = inputString[i];
            const charBytes = Buffer.from(char, 'utf-8').length;

            // Проверяем, можно ли добавить символ к текущей посылке
            if (currentChunkBytes + charBytes <= maxBytes) {
                currentChunk += char;
                currentChunkBytes += charBytes;
            } else {
                // Если символ не помещается, добавляем текущую посылку в результат
                encodedStrings.push(encodeChunk(currentChunk));

                // Начинаем новую посылку
                currentChunk = char;
                currentChunkBytes = charBytes;
            }
        }

        // Добавляем последнюю посылку в результат
        if (currentChunk !== '') {
            encodedStrings.push(encodeChunk(currentChunk));
        }
    } else {
        // Строка помещается в одну посылку
        encodedStrings.push(encodeChunk(inputString));
    }

    // Возвращаем генератор, чтобы поочередно возвращать строки с задержкой
    let index = 0;
    const sendNext = () => {
        if (index < encodedStrings.length) {
            const value = encodedStrings[index++];
            setTimeout(() => {
                node.send({ payload: value });
                sendNext();
            }, 2000); // Задержка в 2 секунд
        }
    };

    sendNext();
}

// Вспомогательная функция для кодирования части строки
function encodeChunk(chunk) {
    let encodedChunk = '';

    // Разбиваем часть строки на слова
    const words = chunk.split(' ');

    // Кодируем каждое слово отдельно
    for (let i = 0; i < words.length; i++) {
        const word = words[i];

        // Проверяем, является ли слово словом в словаре
        if (encodingTable.hasOwnProperty(word)) {
            encodedChunk += encodingTable[word];
        } else {
            // Кодируем каждый символ слова отдельно
            for (let j = 0; j < word.length; j++) {
                const char = word[j];

                // Проверяем, есть ли символ в таблице
                if (encodingTable.hasOwnProperty(char)) {
                    encodedChunk += encodingTable[char];
                } else {
                    // Если символ отсутствует в таблице, оставляем его без изменений
                    encodedChunk += char;
                }
            }
        }

        // Добавляем пробел между словами
        if (i < words.length - 1) {
            encodedChunk += encodingTable[' '];
        }
    }

    return encodedChunk;
}

// Применяем функцию к входной строке
const inputString = msg.payload;
encodeString(inputString);

Код узла функции "Формирование AT команды"
// Получаем входные данные из сообщения msg.payload
var inputString = msg.payload;

// Формируем строку в требуемом формате
var outputString = 'AT+PSEND=' + inputString;

// Создаем новый объект сообщения и устанавливаем в него преобразованную строку
msg.payload = outputString;

// Возвращаем объект сообщения
return msg;

Код узла функции "Задержка на Прием ВКЛ"
// Получаем контекст текущего узла
var context = flow.get('sessionContext') || {};

// Инициализируем массив для хранения сообщений текущей сессии
context.sessionMessages = context.sessionMessages || [];

// Добавляем новые сообщения в массив
context.sessionMessages = context.sessionMessages.concat(msg.payload);

// Устанавливаем таймер на 2,5 секунды для завершения сессии
if (context.sessionTimer) {
    clearTimeout(context.sessionTimer);
}

context.sessionTimer = setTimeout(function () {
    // Когда таймер срабатывает, находим последнее сообщение
    var lastMessage = context.sessionMessages[context.sessionMessages.length - 1];

    // Очищаем контекст для следующей сессии
    context.sessionMessages = [];
    context.sessionTimer = null;

    // Возвращаем последнее сообщение
    node.send({ payload: lastMessage });
}, 2500); // 2,5 секунды таймер

// Если есть еще время до конца сессии, сохраняем контекст
flow.set('sessionContext', context);

// Возвращаем null или что-то еще, чтобы показать, что обработка не завершена
return null;

Код узла функции "Фильтруем Payload"
// Получаем входные данные из сообщения msg.payload
var inputString = msg.payload;

// Разбиваем строку по символу ":" и выбираем последний элемент массива
var parts = inputString.split(':');
var extractedValue = parts[parts.length - 1];

// Создаем новый объект сообщения и устанавливаем в него извлеченное значение
msg.payload = extractedValue;

// Возвращаем объект сообщения
return msg;

Код узла функции "Декодирование данных"
// Установите значения для соответствия символов и их кодировок
const encodingTable = {
    ' ': '00',
    '.': '01',
    ',': '02',
    '!': '03',
    '?': '04',
    '-': '05',
    '(': '06',
    ')': '07',
    'а': '08',
    'б': '09',
    'в': '0A',
    'г': '0B',
    'д': '0C',
    'е': '0D',
    'ж': '0E',
    'з': '0F',
    'и': '10',
    'й': '11',
    'к': '12',
    'л': '13',
    'м': '14',
    'н': '15',
    'о': '16',
    'п': '17',
    'р': '18',
    'с': '19',
    'т': '1A',
    'у': '1B',
    'ф': '1C',
    'х': '1D',
    'ц': '1E',
    'ч': '1F',
    'ш': '20',
    'щ': '21',
    'ъ': '22',
    'ы': '23',
    'ь': '24',
    'э': '25',
    'ю': '26',
    'я': '27',
    'А': '28',
    'Б': '29',
    'В': '2A',
    'Г': '2B',
    'Д': '2C',
    'Е': '2D',
    'Ё': '2E',
    'Ж': '2F',
    'З': '30',
    'И': '31',
    'Й': '32',
    'К': '33',
    'Л': '34',
    'М': '35',
    'Н': '36',
    'О': '37',
    'П': '38',
    'Р': '39',
    'С': '3A',
    'Т': '3B',
    'У': '3C',
    'Ф': '3D',
    'Х': '3E',
    'Ц': '3F',
    'Ч': '40',
    'Ш': '41',
    'Щ': '42',
    'Ъ': '43',
    'Ы': '44',
    'Ь': '45',
    'Э': '46',
    'Ю': '47',
    'Я': '48',
    ', а': '49',
    ', б': '4A',
    ', в': '4B',
    ', г': '4C',
    ', д': '4D',
    ', е': '4E',
    ', ж': '4F',
    ', з': '50',
    ', и': '51',
    ', й': '52',
    ', к': '53',
    ', л': '54',
    ', м': '55',
    ', н': '56',
    ', о': '57',
    ', п': '58',
    ', р': '59',
    ', с': '5A',
    ', т': '5B',
    ', у': '5C',
    ', ф': '5D',
    ', х': '5E',
    ', ц': '5F',
    ', ч': '60',
    ', ш': '61',
    ', щ': '62',
    ', ъ': '63',
    ', ы': '64',
    ', ь': '65',
    ', э': '66',
    ', ю': '67',
    ', я': '68',
    ' а ': '69',
    ' в ': '6A',
    ' и ': '6B',
    ' к ': '6C',
    ' с ': '6D',
    ' у ': '6E',
    ' я ': '6F',
    'будет': '70',
    'буду': '71',
    'бы': '72',
    'был': '73',
    'была': '74',
    'были': '75',
    'вас': '76',
    'вот': '77',
    'все': '78',
    'всех': '79',
    'вы': '7A',
    'где': '7B',
    'для': '7C',
    'его': '7D',
    'ее': '7E',
    'ей': '7F',
    'ем': '80',
    'ему': '81',
    'если': '82',
    'есть': '83',
    'ею': '84',
    'зачем': '85',
    'здесь': '86',
    'из': '87',
    'или': '88',
    'их': '89',
    'как': '8A',
    'какая': '8B',
    'какие': '8C',
    'какое': '8D',
    'какой': '8E',
    'когда': '8F',
    'которая': '90',
    'которое': '91',
    'которые': '92',
    'который': '93',
    'кто': '94',
    'меня': '95',
    'мне': '96',
    'мог': '97',
    'могла': '98',
    'могли': '99',
    'могу': '9A',
    'могут': '9B',
    'мое': '9C',
    'моего': '9D',
    'моей': '9E',
    'может': '9F',
    'можете': 'A0',
    'мои': 'A1',
    'моим': 'A2',
    'моих': 'A3',
    'мой': 'A4',
    'моя': 'A5',
    'мы': 'A6',
    'на': 'A7',
    'наш': 'A8',
    'наша': 'A9',
    'наше': 'AA',
    'наши': 'AB',
    'не': 'AC',
    'него': 'AD',
    'нему': 'AE',
    'ним': 'AF',
    'но': 'B0',
    'он': 'B1',
    'они': 'B2',
    'от': 'B3',
    'отсюда': 'B4',
    'оттуда': 'B5',
    'по': 'B6',
    'потом': 'B7',
    'почему': 'B8',
    'свое': 'B9',
    'свои': 'BA',
    'свой': 'BB',
    'своя': 'BC',
    'сейчас': 'BD',
    'сказал': 'BE',
    'сказала': 'BF',
    'сколько': 'C0',
    'сюда': 'C1',
    'такая': 'C2',
    'также': 'C3',
    'такие': 'C4',
    'такое': 'C5',
    'такой': 'C6',
    'там': 'C7',
    'твое': 'C8',
    'твоего': 'C9',
    'твоей': 'CA',
    'твоем': 'CB',
    'твои': 'CC',
    'твоих': 'CD',
    'твой': 'CE',
    'твоя': 'CF',
    'тебя': 'D0',
    'теперь': 'D1',
    'то': 'D2',
    'тогда': 'D3',
    'туда': 'D4',
    'тут': 'D5',
    'ты': 'D6',
    'что': 'D7',
    'этим': 'D8',
    'это': 'D9',
    'этого': 'DA',
    'этой': 'DB',
    'этому': 'DC',
    'этот': 'DD',
    'хм': 'DE',
    '1': 'DF',
    '2': 'E0',
    '3': 'E1',
    '4': 'E2',
    '5': 'E3',
    '6': 'E4',
    '7': 'E5',
    '8': 'E6',
    '9': 'E7',
    '0': 'E8',
    'Привет': 'E9',
    'Привет,': 'EA',
    'Хабр': 'EB'
};

// Обратное преобразование
function decodeString(encodedString) {
    let decodedString = '';

    for (let i = 0; i < encodedString.length; i += 2) {
        const code = encodedString.substr(i, 2);

        // Поиск символа в таблице
        const char = Object.keys(encodingTable).find(key => encodingTable[key] === code);

        // Если символ найден, добавляем его к раскодированной строке
        if (char) {
            decodedString += char;
        } else {
            // Если символ не найден, оставляем его без изменений
            decodedString += code;
        }
    }

    // Создаем объект сообщения и возвращаем результат
    const msg = { payload: decodedString };
    return msg;
}

// Закодированная строка для обратного преобразования
const encodedString = msg.payload;

// Применяем функцию к закодированной строке
const resultDecoding = decodeString(encodedString);

// Выводим результат в debug
return resultDecoding;

Код узла функции "History"
// Проверяем, если кнопка "clearHistory" была нажата
if (msg.payload === ' ') {
    // Очищаем историю
    context.history = [];
}

// Добавление нового сообщения в историю
context.history = context.history || [];
context.history.push(msg.payload);

// Ограничение длины истории (если нужно)
var maxLength = 25; // Максимальная длина истории
if (context.history.length > maxLength) {
    context.history.shift(); // Удаляем самое старое сообщение
}

// Отправка истории на вход ui_template
return { payload: context.history.join('<br>') };

Текста, конечно, много, но суть всей процедуры довольно проста. Вам нужно подключить LoRa модуль к компьютеру через UART и импортировать файл Flows в Node-RED. Затем указать нужный COM-порт в настройках serial ноды. Останется отправить AT-команды для настройки работы модуля в режиме P2P. После этого вы легко сможете отправлять и принимать сообщения в паре с кем-то, а все отображение будет идти через веб-панель.

Дальности связи при использовании LoRa в зависимости от условий может достигать и 2-3км в плотной городской застройке (по моим тестам в диапазоне 868MHz среднее расстояние было 2км при мощности 14dBm), и до 10-15 км вне города. Все сильно зависит от условий и среды. Если использовать диапазон 433MHz и совместимую антенну, результат будет еще лучше. Рекорд передачи данных по LoRa, например, составляет 1336км, что очень круто.

Если собеседник находится достаточно близко, то для передачи можно использовать SF8 или SF7, где объем одной посылки будет составлять 222 байта.

В данном mvp никак не реализована часть с проверкой доставки сообщений. Самое простое, что можно было бы добавить, это переотправка одного и того же сообщения 2-3 раза. БОльшее значение уже избыточно, так как при существенной потере пакетов никакого нормального взаимодействия уже точно не выйдет.

Теги:
Хабы:
Всего голосов 25: ↑23 и ↓2+27
Комментарии36

Публикации

Истории

Ближайшие события