Three bots from the Python examples, as supervised processes. They share a pattern: subscribe
with an attribute_filters on the channel, reply with send_chan_msg/4, and keep going no matter
what a single message does.
The snippets use the
JSONmodule that ships with Elixir 1.18 and:httpcfrom OTP, so there are no extra dependencies. Swap inReqif you prefer.
Ping bot (serial_pingbot)
Answers ping on one channel with a Pong that includes the hop path of the message it
heard. To know the path it keeps the most recent :rx_log_data summary.
defmodule PingBot do
use GenServer
require Logger
@channel 1
def start_link(mc), do: GenServer.start_link(__MODULE__, mc)
@impl true
def init(mc) do
{:ok, _} = Meshcore.subscribe(mc, :channel_msg_recv, attribute_filters: %{channel_idx: @channel})
{:ok, _} = Meshcore.subscribe(mc, :rx_log_data)
:ok = Meshcore.start_auto_message_fetching(mc)
{:ok, %{mc: mc, pathinfo: "(? hops, ?)"}}
end
@impl true
def handle_info({:meshcore, _, %Meshcore.Event{type: :rx_log_data, payload: p}}, state) do
{:noreply, %{state | pathinfo: format_path(p)}}
end
def handle_info({:meshcore, _, %Meshcore.Event{type: :channel_msg_recv, payload: msg}}, state) do
sender = msg.text |> String.split(":", parts: 2) |> hd() |> String.trim()
if String.contains?(String.downcase(msg.text), "ping") do
reply = "@[#{sender}] Pong 🏓#{state.pathinfo}"
case Meshcore.Commands.send_chan_msg(state.mc, @channel, reply) do
{:ok, _} -> Logger.info("replied: #{reply}")
{:error, e} -> Logger.error("could not reply: #{inspect(e.payload)}")
end
end
{:noreply, state}
end
defp format_path(%{path_len: 0}), do: "(0 hops, direct)"
defp format_path(%{path_len: n, path: path, path_hash_size: size}) when is_integer(n) and n > 0 do
hops = for <<hop::binary-size(size * 2) <- path>>, do: hop
"(#{n} hops, #{Enum.join(hops, ":")})"
end
defp format_path(_), do: "(? hops, ?)"
end
{:ok, mc} = Meshcore.create_serial("/dev/ttyUSB0")
{:ok, _bot} = PingBot.start_link(mc)
Process.sleep(:infinity)(The Python version re-parses the raw hex itself; here Meshcore.PacketParser already did.)
RSS bot (serial_rss_bot)
Polls an RSS feed and posts new items that match a keyword filter to channels, pausing between messages (LoRa duty cycle).
defmodule RssBot do
use GenServer
require Logger
@url ~c"https://data.emergency.vic.gov.au/Show?pageId=getIncidentRSS"
@channels [6]
@poll_ms 300_000
@pause_ms 5_000
@keywords ["BUSHFIRE"]
def start_link(mc), do: GenServer.start_link(__MODULE__, mc)
@impl true
def init(mc) do
# prime: don't spam what's already in the feed
seen = fetch() |> Enum.map(& &1.id) |> MapSet.new()
Process.send_after(self(), :poll, @poll_ms)
{:ok, %{mc: mc, seen: seen}}
end
@impl true
def handle_info(:poll, %{mc: mc, seen: seen} = state) do
items = fetch()
new = items |> Enum.reject(&MapSet.member?(seen, &1.id)) |> Enum.reverse()
for item <- new, text <- List.wrap(message(item)), ch <- @channels do
case Meshcore.Commands.send_chan_msg(mc, ch, text) do
{:ok, _} -> :ok
{:error, e} -> Logger.error("RSS post to channel #{ch} failed: #{inspect(e.payload)}")
end
Process.sleep(@pause_ms)
end
Process.send_after(self(), :poll, @poll_ms)
{:noreply, %{state | seen: Enum.reduce(items, seen, &MapSet.put(&2, &1.id))}}
end
defp message(%{title: title, description: desc}) do
if Enum.any?(@keywords, &String.contains?(String.upcase(desc), &1)),
do: String.slice("#{title} | #{desc}", 0, 135)
end
# Minimal feed reader – use a real XML library (SweetXml, Saxy…) for anything serious.
defp fetch do
{:ok, _} = Application.ensure_all_started(:inets)
{:ok, _} = Application.ensure_all_started(:ssl)
case :httpc.request(:get, {@url, []}, [timeout: 15_000], body_format: :binary) do
{:ok, {{_, 200, _}, _, body}} ->
for [item] <- Regex.scan(~r/<item>(.*?)<\/item>/s, body, capture: :all_but_first) do
%{
id: tag(item, "guid") || tag(item, "link") || tag(item, "title"),
title: tag(item, "title") || "",
description: tag(item, "description") || ""
}
end
other ->
Logger.warning("RSS fetch failed: #{inspect(other)}")
[]
end
end
defp tag(xml, name) do
case Regex.run(~r/<#{name}[^>]*>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/#{name}>/s, xml) do
[_, v] -> String.trim(v)
_ -> nil
end
end
endA failing poll never kills the bot's connection; if the process itself crashes its supervisor
restarts it with a fresh seen set (the priming step prevents re-posting the backlog).
LLM bot (serial_meshcore_ollama)
Answers prompts sent on a channel ("alice: what is LoRa?") using a local
Ollama server, with a hard length cap for the radio.
defmodule OllamaBot do
use GenServer
require Logger
@channel 4
@model "qwen:0.5b"
@max_reply 120
def start_link(mc), do: GenServer.start_link(__MODULE__, mc)
@impl true
def init(mc) do
{:ok, _} = Meshcore.subscribe(mc, :channel_msg_recv, attribute_filters: %{channel_idx: @channel})
:ok = Meshcore.start_auto_message_fetching(mc)
{:ok, mc}
end
@impl true
def handle_info({:meshcore, _, %Meshcore.Event{payload: %{text: text}}}, mc) do
with [sender, prompt] <- String.split(text, ":", parts: 2),
prompt = String.trim(prompt),
true <- prompt != "" do
# don't block the bot while the model thinks
Task.start(fn -> answer(mc, String.trim(sender), prompt) end)
end
{:noreply, mc}
end
defp answer(mc, sender, prompt) do
prefix = "@[#{sender}] "
room = max(@max_reply - String.length(prefix), 0)
reply =
case ask(prompt) do
{:ok, text} -> text |> String.split() |> Enum.join(" ") |> String.slice(0, room)
{:error, _} -> "Sorry, something went wrong on my side." |> String.slice(0, room)
end
Meshcore.Commands.send_chan_msg(mc, @channel, String.slice(prefix <> reply, 0, @max_reply))
end
defp ask(prompt) do
body = JSON.encode!(%{model: @model, stream: false, messages: [%{role: "user", content: prompt}]})
{:ok, _} = Application.ensure_all_started(:inets)
case :httpc.request(:post, {~c"http://localhost:11434/api/chat", [], ~c"application/json", body},
[timeout: 60_000], body_format: :binary) do
{:ok, {{_, 200, _}, _, resp}} ->
{:ok, resp |> JSON.decode!() |> get_in(["message", "content"]) |> String.trim()}
other ->
Logger.warning("ollama request failed: #{inspect(other)}")
{:error, other}
end
end
endPutting a bot under a supervisor
children = [
{Meshcore,
name: MyApp.Radio,
transport: {Meshcore.Transport.Serial, port: "/dev/ttyUSB0"},
auto_reconnect: true,
max_reconnect_attempts: 10},
%{id: PingBot, start: {PingBot, :start_link, [MyApp.Radio]}}
]
Supervisor.start_link(children, strategy: :rest_for_one, name: MyApp.Supervisor)With :rest_for_one the bot restarts (and re-subscribes) if the connection process itself is
restarted.