Однажды встретились Orange PI 5, Heltect v3, свободное время и J4F и в Саратове появился второй LLM бот для Meshtastic. Сегодня расскажу как все это повторить если у вас в одном месте и в одно время появится примерно такое же.
Кратенько про Meshtastic. Сейчас у нас в Саратове по данным https://map.onemesh.ru/ 114 нод, по данным моей ноды - 150 из которых около ~40 постоянно онлайн). В качестве железа этого проекта используется стационарный Heltect v3 с увеличенной антенной закрепленный на окне и подключенный к WiFi и MQTT. Так как нода Meshtastic не умеет мультиконнект, то к ноде подключена интеграция Home Assistant которая умеет работать как прокси. Но это не обязательно, то же самое умеет meshmonitor, его можно запускать как угодно, даже есть инсталяторы под разные OS.
Eще забавный факт, вчера человек летел из Махачкалы с LILYGO T-Echo, судя по flightradar24 в 245км (в районе фролово) от Саратова на высоте примерно 10 км, и мы перекидывались сообщениям с ним почти до его подлета к Тамбову, и даже удалось перекинуться сообщениями с Пензой.
LLM нода - Orange PI 5 8G RAM c 513G m2 SSD. На нем крутится Ubuntu 22.04.5 c ollama и c закаченной моделькой phi4-mini
gals@orangepi5-8g:~ $ ollama list
NAME ID SIZE MODIFIED
phi4-mini:latest 78fad5d182a7 2.5 GB 2 weeks agoТипичная трата ресурсов при обработке запроса из meshtastic такая


А теперь к основной части, самому скрипту бота на python который работает как прослойка между Meshatastic и Ollama . Вообще скрипт задумывался как тупой пересыльщик сообщений из Meshatastic в телегу, а так же записыватель базы всех сообщений и нод в sqlite3 базу. Там все тупо, скучно и не интересно. На интересных особенностях остановимся далее. Актуальная версия доступна на github, вместе с systemd service, env и requirements.txt
Пробежимся по интерeсным моментам
Кроме основного дефолтного канала у меня в Meshtastic настроены каналы со своими отдельными ключами шифрования. 1 - семья, 2 - друзья, они зашиты в константу CHANNELS
Так как в Meshtastic есть ограничение на размер в сообщении в 230 байт, притом что кириллица это 2 байта, а emoji могут быть больше, а так же потому что ресурсы llm ноды ограничены мы добавляем системный промпт "Ты чатбот для Meshtastic. Отвечай по-русски. Ответ строго <= 110 символов. Без списков, без пояснений, без приветствий." в функцию ollama_reply, так же туда же добавляем "temperature": 0.4, "top_p": 0.9, что бы ответ был быстрее. Так как LLM может выдать ответ длиннее чем можно, и сообщения тогда вообще не уйдет ответ еще проходит через функцию clamp_200 которая обрезает сообщение до 110 символов. У нас в Cаратове как-то сложилось что обе модели LLM отвечают только на сообщения которые начинаются с !llm и это тупо захардкожено прямо в скрипте.
Скрытый текст
def ollama_reply(prompt: str) -> str:
# Request to model to be very short
system = (
"Ты чатбот для Meshtastic. "
"Отвечай по-русски. "
"Ответ строго <= 110 символов. "
"Без списков, без пояснений, без приветствий."
)
payload = {
"model": OLLAMA_MODEL,
"prompt": f"{system}\n\nВопрос: {prompt}\nОтвет:",
"stream": False,
# Not speed up
"options": {
"temperature": 0.4,
"top_p": 0.9,
},
}Второй интересный момент - функция send_response. В meshtastic есть 3 вида текстовых сообщений, обычные, ответы (ака thread) и так же реакции на сообщения. При этом API sendText из API умеет только первый тип, для обхода этого ограничения send_response использует _sendPacket из API напрямую. Что забавно, в реакции можно пихать не только emoji но и текст. В скрипте для примера тоже захордкожены реакции на hi и пинг
Выглядит это примерно так

Говорят что это работает только в приложении на Android, а на IOS этого нет, но сам не проверял.
Собственно у нас в саратове 2 llm, одна моя, вторая вроде крутится на видюхе (к сожалению и модель и модель видюхи забыл), и сама посерьезнеe и побольше, на скринах дальше можно глянуть ответы и сравнить их скорость. sg-n это описываемая тут нода, а NWAY - другая. Ради забавы ради позадавал вопросы и на китайском.
Скрытый текст





По идее если добавить в скрипте в ответ бота !llm то можно устроить теоретически бесконечный диалог :) но канал не резиновый и небольшую рекурсию можно устроить и одним запросом, напоминаю что sg-h это бот

В целом модель phi4-mini ведет себя отлично, даже без ограничений которые наложены запросом из мештастик бота. Эта же модель подключена к телеграм боту основанному на вот этом проекте где ограничений нет, и там она вполне хорошо держит конекст в диалоге, выдает довольно релевантные ответы, но иногда может призадуматься.


Если, вдруг, вы дочитали до конца и у вас есть Home Assistant c настроенной интеграцией с Meshtastci, то вот вам бонусом HA автоматизация которая по запросу !w выдает текущую погоду в канал
Скрытый текст
alias: Meshatic Weather
description: ""
triggers:
- domain: meshtastic
type: channel_message.received
entity_id: meshtastic.gateway_sg_h_channel_primary
trigger: device
conditions:
- condition: template
value_template: "{{ \"!w\" in (trigger.event.data.message | lower) }}"
actions:
- action: weather.get_forecasts
metadata: {}
data:
type: daily
enabled: false
- delay:
hours: 0
minutes: 0
seconds: 12
milliseconds: 0
- action: meshtastic.broadcast_channel_message
metadata: {}
data:
ack: true
channel: meshtastic.gateway_sg_h_channel_primary
message: >-
Погода: 🌡️ {{state_attr('weather.home', 'temperature')
}}{{state_attr('weather.home', 'temperature_unit')}} 💧
{{state_attr('weather.home', 'humidity')}}% 💨
{{state_attr('weather.home', 'wind_speed')}}{{state_attr('weather.home',
'wind_speed_unit')}} / {{ ((state_attr('weather.home', 'wind_speed') |
float(0)) / 3.6) | round(1) }}m/s {% set deg =
state_attr('weather.home', 'wind_bearing') | float(0) %}{% set dirs = [
"С",
"СВ",
"В",
"ЮВ",
"Ю",
"ЮЗ",
"З",
"СЗ"
] %}{% set idx = ((deg + 22.5) // 45) | int %} 💨↗️ {{ dirs[idx % 8]
}}({{ deg }})° 🌥️ {{state_attr('weather.home', 'cloud_coverage')}}%
☀️(uv_index) {{state_attr('weather.home', 'uv_index')}} 🏋️
{{state_attr('weather.home', 'pressure')}}{{state_attr('weather.home',
'pressure_unit')}}{% set hpa = state_attr('weather.home', 'pressure') |
float(0) %}{% set mmhg = (hpa * 0.750064) | round(0) %}{% if mmhg < 745
%}{% set level = "низкое" %}{% elif mmhg <= 765 %}{% set level = "норм"
%}{% else %}{% set level = "высокое" %}{% endif %}/{{ mmhg }}mm ({{
level }}). {% set t = states('weather.home') %}{% set map = {
'sunny': 'клево и солнечно',
'clear-night': 'Темно и ясно',
'cloudy': 'облачно немношк',
'partlycloudy': 'Иногда облачность',
'rainy': 'лужи и дождь',
'pouring': 'не забываем зонт, ливень',
'snowy': 'готовьте лыжи, снег',
'snowy-rainy': 'херовато, снег с дождём',
'windy': 'ветрено нафик',
'windy-variant': 'может сдуть нафик',
'fog': 'загадочно и туманно',
'hail': 'не забудьте каску, град',
'lightning': 'в укрвтие, гроза',
'lightning-rainy': 'Гроза с дождём'
} %}Воопщем - {{ map[t] if t in map else t }}
mode: singleв тексте выглядит это примерно так

