Initial commit
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Virtual envs
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Tooling caches
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Local config & runtime data
|
||||
config.toml
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
@@ -0,0 +1,29 @@
|
||||
# meshbot
|
||||
|
||||
Bridges a [MeshCore](https://meshcore.io) companion radio to any OpenAI-compatible LLM endpoint
|
||||
(e.g. `llama-server`, vLLM, Ollama). Listens for direct messages on the device, runs each
|
||||
conversation through the LLM with full per-sender history stored in SQLite, and replies back
|
||||
over the mesh — trimmed to the MeshCore packet payload limit.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e .
|
||||
|
||||
cp config.example.toml config.toml
|
||||
# edit serial_port and [llm] in config.toml
|
||||
|
||||
python -m meshbot
|
||||
```
|
||||
|
||||
Config file path defaults to `./config.toml` and can be overridden with `MESHBOT_CONFIG`.
|
||||
Any field can be overridden via env vars, e.g. `MESHBOT_LLM__API_KEY=sk-...`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/meshbot/bot.py` — connect, subscribe to `CONTACT_MSG_RECV`, dispatch each DM.
|
||||
- `src/meshbot/db.py` — SQLite schema and per-conversation repo functions.
|
||||
- `src/meshbot/llm.py` — `AsyncOpenAI` wrapper.
|
||||
- `src/meshbot/messages.py` — UTF-8-safe byte-length trimming.
|
||||
- `src/meshbot/config.py` — TOML + env-var settings (pydantic-settings).
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copy this file to `config.toml` and edit. The path can be overridden with
|
||||
# the MESHBOT_CONFIG environment variable. Any field can be overridden with
|
||||
# environment variables of the form MESHBOT_<SECTION>__<KEY>, e.g.
|
||||
# MESHBOT_LLM__BASE_URL=http://llama:8080/v1
|
||||
# MESHBOT_MESHCORE__SERIAL_PORT=/dev/ttyACM0
|
||||
|
||||
[meshcore]
|
||||
serial_port = "/dev/ttyUSB0"
|
||||
baud_rate = 115200
|
||||
|
||||
[llm]
|
||||
base_url = "http://localhost:8080/v1"
|
||||
api_key = "not-needed"
|
||||
model = "llama-3.1-8b-instruct"
|
||||
system_prompt = "You are a concise assistant on a low-bandwidth mesh radio. Replies must be brief — under 180 bytes."
|
||||
temperature = 0.7
|
||||
request_timeout_seconds = 60
|
||||
|
||||
[storage]
|
||||
sqlite_path = "data/meshbot.db"
|
||||
|
||||
[message]
|
||||
# MeshCore MAX_PACKET_PAYLOAD is 184 bytes. Lower this if your text-frame
|
||||
# headers further constrain the usable payload on your device.
|
||||
max_bytes = 184
|
||||
@@ -0,0 +1,41 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meshbot"
|
||||
version = "0.1.0"
|
||||
description = "Bridge a MeshCore companion radio to an OpenAI-compatible LLM endpoint."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Tobias Huttinger" }]
|
||||
dependencies = [
|
||||
"meshcore>=2.3",
|
||||
"openai>=1.40",
|
||||
"pydantic>=2.7",
|
||||
"pydantic-settings>=2.4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"ruff>=0.6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
meshbot = "meshbot.__main__:_cli"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""meshbot — MeshCore ↔ LLM bridge."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Entry point: ``python -m meshbot`` and the ``meshbot`` console script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .bot import run
|
||||
|
||||
|
||||
def _cli() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
)
|
||||
try:
|
||||
asyncio.run(run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Main run loop: connect to the MeshCore device, route DMs through the LLM, reply."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from meshcore import EventType, MeshCore
|
||||
|
||||
from . import db
|
||||
from .config import Settings
|
||||
from .llm import LLMClient
|
||||
from .messages import trim_to_bytes
|
||||
|
||||
log = logging.getLogger("meshbot")
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
cfg = Settings()
|
||||
|
||||
db_conn = db.connect(cfg.storage.sqlite_path)
|
||||
llm = LLMClient(
|
||||
base_url=cfg.llm.base_url,
|
||||
api_key=cfg.llm.api_key,
|
||||
model=cfg.llm.model,
|
||||
system_prompt=cfg.llm.system_prompt,
|
||||
temperature=cfg.llm.temperature,
|
||||
timeout=cfg.llm.request_timeout_seconds,
|
||||
)
|
||||
|
||||
log.info("connecting to MeshCore on %s @ %d baud", cfg.meshcore.serial_port, cfg.meshcore.baud_rate)
|
||||
mc = await MeshCore.create_serial(cfg.meshcore.serial_port, cfg.meshcore.baud_rate)
|
||||
await mc.ensure_contacts()
|
||||
|
||||
# One lock per sender so a burst of messages from the same peer is processed
|
||||
# serially while different peers stay independent.
|
||||
locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
|
||||
async def on_dm(event) -> None:
|
||||
data = event.payload or {}
|
||||
prefix = data.get("pubkey_prefix")
|
||||
text = (data.get("text") or "").strip()
|
||||
if not prefix or not text:
|
||||
return
|
||||
|
||||
contact = mc.get_contact_by_key_prefix(prefix)
|
||||
if contact is None:
|
||||
log.info("ignoring DM from unknown sender %s", prefix)
|
||||
return
|
||||
|
||||
public_key = contact["public_key"]
|
||||
contact_name = contact.get("adv_name", "")
|
||||
log.info("DM from %s (%s): %s", contact_name, public_key[:12], text)
|
||||
|
||||
async with locks[public_key]:
|
||||
db.upsert_conversation(db_conn, public_key, contact_name)
|
||||
db.add_message(db_conn, public_key, "user", text)
|
||||
history = db.get_history(db_conn, public_key)
|
||||
|
||||
try:
|
||||
reply = await llm.reply(history)
|
||||
except Exception:
|
||||
log.exception("LLM call failed for %s", public_key[:12])
|
||||
return
|
||||
|
||||
db.add_message(db_conn, public_key, "assistant", reply)
|
||||
outgoing = trim_to_bytes(reply, cfg.message.max_bytes)
|
||||
log.info("reply to %s (%d bytes): %s", public_key[:12], len(outgoing.encode("utf-8")), outgoing)
|
||||
|
||||
result = await mc.commands.send_msg(contact, outgoing)
|
||||
if result.type == EventType.ERROR:
|
||||
log.error("send_msg failed for %s: %s", public_key[:12], result.payload)
|
||||
|
||||
sub = mc.subscribe(EventType.CONTACT_MSG_RECV, on_dm)
|
||||
await mc.start_auto_message_fetching()
|
||||
log.info("meshbot listening on %s", cfg.meshcore.serial_port)
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
mc.unsubscribe(sub)
|
||||
await mc.stop_auto_message_fetching()
|
||||
await mc.disconnect()
|
||||
await llm.aclose()
|
||||
db_conn.close()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Application settings loaded from TOML with env-var overrides."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import (
|
||||
BaseSettings,
|
||||
PydanticBaseSettingsSource,
|
||||
SettingsConfigDict,
|
||||
TomlConfigSettingsSource,
|
||||
)
|
||||
|
||||
|
||||
class MeshCoreCfg(BaseModel):
|
||||
serial_port: str
|
||||
baud_rate: int = 115200
|
||||
|
||||
|
||||
class LLMCfg(BaseModel):
|
||||
base_url: str
|
||||
api_key: str = "not-needed"
|
||||
model: str
|
||||
system_prompt: str = (
|
||||
"You are a concise assistant on a low-bandwidth mesh radio. "
|
||||
"Replies must be brief — under 180 bytes."
|
||||
)
|
||||
temperature: float = 0.7
|
||||
request_timeout_seconds: float = 60.0
|
||||
|
||||
|
||||
class StorageCfg(BaseModel):
|
||||
sqlite_path: Path = Path("data/meshbot.db")
|
||||
|
||||
|
||||
class MessageCfg(BaseModel):
|
||||
max_bytes: int = Field(default=184, gt=0)
|
||||
|
||||
|
||||
def _toml_path() -> Path:
|
||||
return Path(os.environ.get("MESHBOT_CONFIG", "config.toml"))
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
meshcore: MeshCoreCfg
|
||||
llm: LLMCfg
|
||||
storage: StorageCfg = StorageCfg()
|
||||
message: MessageCfg = MessageCfg()
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="MESHBOT_",
|
||||
env_nested_delimiter="__",
|
||||
toml_file=_toml_path(),
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||
# Order = priority (highest first): init args > env > TOML > secrets.
|
||||
return (
|
||||
init_settings,
|
||||
env_settings,
|
||||
TomlConfigSettingsSource(settings_cls),
|
||||
file_secret_settings,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""SQLite persistence for per-sender conversation history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
contact_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL REFERENCES conversations(public_key),
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_pubkey_id
|
||||
ON messages(public_key, id);
|
||||
"""
|
||||
|
||||
|
||||
def connect(path: str | Path) -> sqlite3.Connection:
|
||||
"""Open the SQLite DB, ensure the parent dir and schema exist, return the connection."""
|
||||
db_path = Path(path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path, isolation_level=None) # autocommit
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode = WAL;")
|
||||
conn.execute("PRAGMA foreign_keys = ON;")
|
||||
conn.executescript(SCHEMA)
|
||||
return conn
|
||||
|
||||
|
||||
def upsert_conversation(conn: sqlite3.Connection, public_key: str, contact_name: str) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO conversations (public_key, contact_name)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
contact_name = excluded.contact_name,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(public_key, contact_name),
|
||||
)
|
||||
|
||||
|
||||
def add_message(conn: sqlite3.Connection, public_key: str, role: str, content: str) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO messages (public_key, role, content) VALUES (?, ?, ?)",
|
||||
(public_key, role, content),
|
||||
)
|
||||
|
||||
|
||||
def get_history(conn: sqlite3.Connection, public_key: str) -> list[dict[str, str]]:
|
||||
"""Return the conversation as OpenAI chat messages, oldest first."""
|
||||
rows = conn.execute(
|
||||
"SELECT role, content FROM messages WHERE public_key = ? ORDER BY id ASC",
|
||||
(public_key,),
|
||||
).fetchall()
|
||||
return [{"role": row["role"], "content": row["content"]} for row in rows]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Thin wrapper around the OpenAI Python SDK aimed at OpenAI-compatible servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
temperature: float,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
self._client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=timeout)
|
||||
self._model = model
|
||||
self._system_prompt = system_prompt
|
||||
self._temperature = temperature
|
||||
|
||||
async def reply(self, history: list[dict[str, str]]) -> str:
|
||||
"""Send the system prompt + ``history`` and return the assistant's text."""
|
||||
messages: list[dict[str, str]] = [
|
||||
{"role": "system", "content": self._system_prompt},
|
||||
*history,
|
||||
]
|
||||
resp = await self._client.chat.completions.create(
|
||||
model=self._model,
|
||||
messages=messages,
|
||||
temperature=self._temperature,
|
||||
)
|
||||
return (resp.choices[0].message.content or "").strip()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.close()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Helpers for shaping outgoing mesh messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def trim_to_bytes(text: str, max_bytes: int) -> str:
|
||||
"""Return ``text`` truncated so its UTF-8 encoding is at most ``max_bytes`` bytes.
|
||||
|
||||
Backs off if the cut lands inside a multi-byte UTF-8 sequence so we never emit
|
||||
invalid UTF-8 to the radio.
|
||||
"""
|
||||
if max_bytes <= 0:
|
||||
return ""
|
||||
encoded = text.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text
|
||||
cut = encoded[:max_bytes]
|
||||
# Continuation bytes start with bits 10xxxxxx; rewind past them.
|
||||
while cut and (cut[-1] & 0xC0) == 0x80:
|
||||
cut = cut[:-1]
|
||||
return cut.decode("utf-8", errors="ignore")
|
||||
@@ -0,0 +1,40 @@
|
||||
from meshbot.messages import trim_to_bytes
|
||||
|
||||
|
||||
def test_short_ascii_passthrough():
|
||||
assert trim_to_bytes("hello", 184) == "hello"
|
||||
|
||||
|
||||
def test_exact_fit_passthrough():
|
||||
s = "a" * 184
|
||||
assert trim_to_bytes(s, 184) == s
|
||||
|
||||
|
||||
def test_long_ascii_clean_cut():
|
||||
s = "x" * 200
|
||||
out = trim_to_bytes(s, 184)
|
||||
assert len(out.encode("utf-8")) == 184
|
||||
assert out == "x" * 184
|
||||
|
||||
|
||||
def test_emoji_does_not_split():
|
||||
# Each 🎉 is 4 UTF-8 bytes. Limit of 5 must keep just one emoji (4 bytes), not 5.
|
||||
out = trim_to_bytes("🎉🎉", 5)
|
||||
assert out == "🎉"
|
||||
assert len(out.encode("utf-8")) == 4
|
||||
|
||||
|
||||
def test_multibyte_at_boundary():
|
||||
# "ä" is 2 bytes in UTF-8. With a 3-byte budget for "aä" (3 bytes total), we keep both.
|
||||
assert trim_to_bytes("aä", 3) == "aä"
|
||||
# With a 2-byte budget we can only keep the leading "a".
|
||||
assert trim_to_bytes("aä", 2) == "a"
|
||||
|
||||
|
||||
def test_zero_or_negative_max_bytes():
|
||||
assert trim_to_bytes("anything", 0) == ""
|
||||
assert trim_to_bytes("anything", -1) == ""
|
||||
|
||||
|
||||
def test_empty_input():
|
||||
assert trim_to_bytes("", 184) == ""
|
||||
Reference in New Issue
Block a user