From 78dfac07c6c01e852a2943341051c90eefeb1d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9Flar=20YEYEN?= Date: Mon, 2 Nov 2026 01:29:37 +0300 Subject: [PATCH 1/3] Add optional mesh chat bridge for Meschy / Meshtastic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plug a Meschy (or any Meshtastic node) into the Pi and everyone on the hotspot can read and send LoRa mesh messages at http://box.lan/mesh/ without the Meshtastic app. Messages are stored in SQLite on the SSD. - mesh/meshchat.py: serial connection with auto-reconnect, SQLite log, small JSON API, per-client cooldown, UTF-8 byte limit, delivery status from implicit ACKs (relayed / unheard) - mesh/static/index.html: mobile chat page in the homepage style - systemd unit, nginx location snippet for IIAB, defaults file - scripts/install-meshchat.sh: one-shot installer - homepage: "Haberleşme > Mesh Sohbet" entry with live radio status - README: EN/TR setup section Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TDZYMBtWvwsxGrYQDjMMjs --- README.md | 32 +++ mesh/has-meshchat.default | 24 ++ mesh/has-meshchat.service | 31 +++ mesh/meshchat-nginx.conf | 15 ++ mesh/meshchat.py | 446 ++++++++++++++++++++++++++++++++++++ mesh/requirements.txt | 1 + mesh/static/index.html | 310 +++++++++++++++++++++++++ scripts/install-meshchat.sh | 92 ++++++++ www/home/index.html | 22 +- 9 files changed, 970 insertions(+), 3 deletions(-) create mode 100644 mesh/has-meshchat.default create mode 100644 mesh/has-meshchat.service create mode 100644 mesh/meshchat-nginx.conf create mode 100755 mesh/meshchat.py create mode 100644 mesh/requirements.txt create mode 100644 mesh/static/index.html create mode 100755 scripts/install-meshchat.sh diff --git a/README.md b/README.md index dea4d7c..ba961df 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,20 @@ The Pi's built-in Wi-Fi is dedicated to the hotspot. If the Pi needs internet te **Never run `sudo iiab-network` while a USB Wi-Fi adapter is plugged in.** It may move the hotspot onto the adapter. Unplug it first. +### Optional: mesh chat with Meschy + +Plug a [Meschy](https://github.com/schematrix/meschy) (or any Meshtastic node) into the Pi and everyone on the hotspot can send and read LoRa mesh messages from a browser, with no Meshtastic app. Messages are saved on the SSD, so people who join later still see the history. LoRa carries short text only; it cannot share Wikipedia pages or maps. + +1. Flash Meshtastic on the Meschy and set the region to `EU_868` with the Meshtastic app (see the Meschy README). +2. Plug it into a black USB 2.0 port. Keep the antenna outside any metal case. +3. Install (needs internet once): + ```bash + sudo ./scripts/install-meshchat.sh + ``` +4. Open **http://box.lan/mesh/**. The homepage also gets a "Mesh Sohbet" entry. + +Everyone on the page shares the Pi's radio, so each message starts with the sender's nickname. Messages go to the primary channel; direct messages to the Pi's node are shown to everyone on the page. Settings live in `/etc/default/has-meshchat`, logs in `journalctl -u has-meshchat`. + ### Repo layout ``` @@ -82,6 +96,8 @@ scripts/install-homepage.sh installs the custom homepage config/iiab-overrides.yml Internet-in-a-Box settings config/content.conf list of content to download www/home/index.html homepage +scripts/install-meshchat.sh installs the mesh chat bridge (optional) +mesh/ mesh chat service, page, systemd and nginx files ``` ### Troubleshooting @@ -186,6 +202,20 @@ Pi'nin dahili Wi-Fi kartı sadece hotspot için kullanılıyor. Pi'nin geçici o **USB Wi-Fi adaptörü takılıyken asla `sudo iiab-network` çalıştırma.** Hotspot'u adaptöre taşıyabilir. Önce adaptörü çıkar. +### İsteğe bağlı: Meschy ile mesh sohbet + +Pi'ye bir [Meschy](https://github.com/schematrix/meschy) (ya da herhangi bir Meshtastic cihazı) takarsan, hotspot'a bağlanan herkes Meshtastic uygulaması olmadan tarayıcıdan LoRa mesh ağına mesaj atıp okuyabilir. Mesajlar SSD'ye kaydedilir, sonradan bağlanan da geçmişi görür. LoRa sadece kısa metin taşır; Wikipedia sayfası ya da harita paylaşamaz. + +1. Meschy'ye Meshtastic yükle ve Meshtastic uygulamasından bölgeyi `EU_868` yap (Meschy README'sine bak). +2. Siyah USB 2.0 portuna tak. Anten metal bir kasanın içinde kalmasın. +3. Kur (bir kereliğine internet gerekir): + ```bash + sudo ./scripts/install-meshchat.sh + ``` +4. **http://box.lan/mesh/** adresini aç. Ana sayfaya da "Mesh Sohbet" bölümü eklenir. + +Sayfadaki herkes Pi'nin radyosunu ortak kullanır, bu yüzden her mesajın başına gönderenin takma adı eklenir. Mesajlar birincil kanala gider; Pi'nin cihazına gelen özel mesajlar sayfadaki herkese görünür. Ayarlar `/etc/default/has-meshchat` dosyasında, kayıtlar `journalctl -u has-meshchat` ile görülür. + ### Repo yapısı ``` @@ -196,6 +226,8 @@ scripts/install-homepage.sh özel ana sayfayı kurar config/iiab-overrides.yml Internet-in-a-Box ayarları config/content.conf indirilecek içerik listesi www/home/index.html ana sayfa +scripts/install-meshchat.sh mesh sohbet köprüsünü kurar (isteğe bağlı) +mesh/ mesh sohbet servisi, sayfası, systemd ve nginx dosyaları ``` ### Sorun giderme diff --git a/mesh/has-meshchat.default b/mesh/has-meshchat.default new file mode 100644 index 0000000..3a4b905 --- /dev/null +++ b/mesh/has-meshchat.default @@ -0,0 +1,24 @@ +# HAS_PrepperDisk mesh sohbet ayarları / mesh chat settings +# Değiştirdikten sonra / after editing: sudo systemctl restart has-meshchat + +# Seri port. Boş bırakılırsa otomatik bulunur. Birden fazla USB seri cihaz varsa +# /dev/serial/by-id/ altındaki tam yolu yaz. +# Serial port; empty = auto-detect. With several USB serial devices, use the +# full path under /dev/serial/by-id/. +MESHCHAT_DEVICE= + +# Mesajların gönderileceği kanal (0 = birincil kanal) +# Channel index to send on (0 = primary channel) +MESHCHAT_CHANNEL=0 + +# Takma ad dahil en fazla bayt (Meshtastic sınırı ~228) +# Max bytes including the nickname (Meshtastic limit ~228) +MESHCHAT_MAX_BYTES=200 + +# Aynı cihazdan iki mesaj arasında beklenecek saniye (mesh'i korur) +# Seconds a client must wait between messages (protects the mesh) +MESHCHAT_COOLDOWN=5 + +MESHCHAT_DB=/library/meshchat/meshchat.db +MESHCHAT_HOST=127.0.0.1 +MESHCHAT_PORT=8095 diff --git a/mesh/has-meshchat.service b/mesh/has-meshchat.service new file mode 100644 index 0000000..17027c7 --- /dev/null +++ b/mesh/has-meshchat.service @@ -0,0 +1,31 @@ +[Unit] +Description=HAS_PrepperDisk mesh chat (Meshtastic <-> web bridge) +After=network.target local-fs.target +RequiresMountsFor=/library + +[Service] +Type=simple +User=meshchat +Group=meshchat +SupplementaryGroups=dialout +Environment=HOME=/library/meshchat PYTHONUNBUFFERED=1 +EnvironmentFile=-/etc/default/has-meshchat +WorkingDirectory=/opt/has-meshchat +ExecStart=/opt/has-meshchat/venv/bin/python /opt/has-meshchat/meshchat.py +Restart=always +RestartSec=5 + +# Sıkılaştırma / hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/library/meshchat +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes + +[Install] +WantedBy=multi-user.target diff --git a/mesh/meshchat-nginx.conf b/mesh/meshchat-nginx.conf new file mode 100644 index 0000000..158b725 --- /dev/null +++ b/mesh/meshchat-nginx.conf @@ -0,0 +1,15 @@ +# HAS_PrepperDisk mesh sohbet: http://box.lan/mesh/ -> 127.0.0.1:8095 +# Internet-in-a-Box bu klasördeki dosyaları server bloğunun içine ekler. +# Internet-in-a-Box includes files from /etc/nginx/conf.d inside its server block. +location = /mesh { + return 301 /mesh/; +} +location ^~ /mesh/ { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $http_host; + proxy_http_version 1.1; + proxy_set_header Connection ""; + client_max_body_size 8k; + proxy_read_timeout 30s; + proxy_pass http://127.0.0.1:8095/; +} diff --git a/mesh/meshchat.py b/mesh/meshchat.py new file mode 100755 index 0000000..aa3e90a --- /dev/null +++ b/mesh/meshchat.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +"""HAS_PrepperDisk mesh sohbet köprüsü / mesh chat bridge. + +USB ile bağlı bir Meshtastic cihazını (Meschy) Pi'nin Wi-Fi ağına açar. +Hotspot'a bağlanan herkes tarayıcıdan mesh ağına mesaj atıp okuyabilir. +Mesajlar SQLite'a kaydedilir, böylece sonradan bağlanan da geçmişi görür. + +Bridges a USB-attached Meshtastic node (Meschy) to the Pi's Wi-Fi network. +Anyone on the hotspot can read and send mesh messages from a browser. +Messages are stored in SQLite so people who join later can see the history. + +Ayarlar ortam değişkenleriyle yapılır (/etc/default/has-meshchat): +Settings come from environment variables (/etc/default/has-meshchat): + MESHCHAT_DEVICE seri port, boşsa otomatik / serial port, empty = auto + MESHCHAT_DB SQLite dosyası / database file + MESHCHAT_HOST dinlenen adres / listen address (127.0.0.1, behind nginx) + MESHCHAT_PORT dinlenen port / listen port (8095) + MESHCHAT_CHANNEL gönderilen kanal / channel index to send on (0 = primary) + MESHCHAT_MAX_BYTES takma ad dahil mesaj sınırı / max message bytes incl. nickname + MESHCHAT_COOLDOWN aynı cihazdan iki mesaj arası saniye / seconds between sends per client +""" + +import glob +import json +import logging +import os +import re +import sqlite3 +import threading +import time +import unicodedata +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +log = logging.getLogger("meshchat") + +DEVICE = os.getenv("MESHCHAT_DEVICE", "").strip() +DB_PATH = os.getenv("MESHCHAT_DB", "/library/meshchat/meshchat.db") +HOST = os.getenv("MESHCHAT_HOST", "127.0.0.1") +PORT = int(os.getenv("MESHCHAT_PORT", "8095")) +CHANNEL = int(os.getenv("MESHCHAT_CHANNEL", "0")) +# Meshtastic paket yükü en fazla 233 bayt; biraz pay bırakıyoruz. +# A Meshtastic payload is at most 233 bytes; keep some headroom. +MAX_BYTES = min(int(os.getenv("MESHCHAT_MAX_BYTES", "200")), 228) +COOLDOWN = float(os.getenv("MESHCHAT_COOLDOWN", "5")) +MAX_NICK = 16 +RETRY_SECONDS = 10 +STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") + + +def clean_text(value, limit_chars): + """Kontrol karakterlerini at, boşlukları sadeleştir / drop control chars, squeeze spaces.""" + value = unicodedata.normalize("NFC", str(value)) + value = "".join(ch for ch in value if unicodedata.category(ch)[0] != "C" or ch == " ") + value = re.sub(r"\s+", " ", value).strip() + return value[:limit_chars] + + +# --------------------------------------------------------------------- storage +class Store: + def __init__(self, path): + if os.path.dirname(path): + os.makedirs(os.path.dirname(path), exist_ok=True) + self.lock = threading.Lock() + self.db = sqlite3.connect(path, check_same_thread=False) + self.db.row_factory = sqlite3.Row + with self.lock: + self.db.executescript( + """ + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + packet_id INTEGER, + ts REAL NOT NULL, + direction TEXT NOT NULL, -- in / out + from_id TEXT, + from_name TEXT, + to_id TEXT, + channel INTEGER, + text TEXT NOT NULL, + snr REAL, + rssi INTEGER, + hops INTEGER, + status TEXT -- out: queued/sent/relayed/failed + ); + CREATE UNIQUE INDEX IF NOT EXISTS uniq_packet + ON messages(direction, from_id, packet_id); + """ + ) + self.db.commit() + + def add(self, **row): + cols = ", ".join(row) + marks = ", ".join("?" for _ in row) + with self.lock: + cur = self.db.execute( + f"INSERT OR IGNORE INTO messages ({cols}) VALUES ({marks})", tuple(row.values()) + ) + self.db.commit() + return cur.lastrowid if cur.rowcount else None + + def set_status(self, row_id, status): + with self.lock: + self.db.execute("UPDATE messages SET status=? WHERE id=?", (status, row_id)) + self.db.commit() + + def mark_sent(self, row_id, packet_id): + # Onay sendData dönmeden gelmiş olabilir; onu ezme. + # The ACK may already have arrived before sendData returned; don't overwrite it. + with self.lock: + self.db.execute( + "UPDATE messages SET packet_id=?, " + "status=CASE WHEN status='queued' THEN 'sent' ELSE status END WHERE id=?", + (packet_id, row_id), + ) + self.db.commit() + + def since(self, after_id, limit): + with self.lock: + if after_id > 0: + rows = self.db.execute( + "SELECT * FROM messages WHERE id>? ORDER BY id LIMIT ?", (after_id, limit) + ).fetchall() + else: # ilk yükleme: son N mesaj / first load: last N messages + rows = self.db.execute( + "SELECT * FROM (SELECT * FROM messages ORDER BY id DESC LIMIT ?) ORDER BY id", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + def updated_statuses(self, ids): + if not ids: + return {} + with self.lock: + rows = self.db.execute( + f"SELECT id, status FROM messages WHERE id IN ({','.join('?' * len(ids))})", ids + ).fetchall() + return {r["id"]: r["status"] for r in rows} + + +# ----------------------------------------------------------------------- radio +def find_device(): + """Meshtastic cihazını bul / find the Meshtastic serial device.""" + if DEVICE: + return DEVICE if os.path.exists(DEVICE) else None + try: + import meshtastic.util + + ports = meshtastic.util.findPorts(True) + if ports: + return ports[0] + except Exception as exc: # pragma: no cover - depends on hardware + log.debug("findPorts failed: %s", exc) + for pattern in ("/dev/serial/by-id/*", "/dev/ttyACM*", "/dev/ttyUSB*"): + found = sorted(glob.glob(pattern)) + if found: + return found[0] + return None + + +class Radio: + """Meshtastic bağlantısını yönetir, koparsa yeniden bağlanır. + Keeps the Meshtastic connection alive and reconnects when it drops.""" + + def __init__(self, store, interface_factory=None): + self.store = store + self.iface = None + self.device = None + self.error = "Başlatılıyor / starting" + self.lock = threading.Lock() + self.lost = threading.Event() + self._factory = interface_factory + + # pubsub geri çağrıları / pubsub callbacks ------------------------------- + def on_text(self, packet, interface=None): + try: + decoded = packet.get("decoded", {}) + text = decoded.get("text") + if not text: + return + from_id = packet.get("fromId") or f"!{packet.get('from', 0):08x}" + my_id = self.my_id() + if from_id == my_id: + return # kendi gönderdiğimiz / our own message + hop_start, hop_limit = packet.get("hopStart"), packet.get("hopLimit") + self.store.add( + packet_id=packet.get("id"), + ts=packet.get("rxTime") or time.time(), + direction="in", + from_id=from_id, + from_name=self.node_name(from_id), + to_id=packet.get("toId") or "^all", + channel=packet.get("channel", 0), + text=clean_text(text, 500), + snr=packet.get("rxSnr"), + rssi=packet.get("rxRssi"), + hops=(hop_start - hop_limit) if hop_start is not None and hop_limit is not None else None, + status=None, + ) + except Exception: + log.exception("could not store incoming packet") + + def on_lost(self, interface=None): + log.warning("Radyo bağlantısı koptu / radio connection lost") + self.lost.set() + + # yardımcılar / helpers --------------------------------------------------- + def connected(self): + iface = self.iface + return bool(iface and iface.isConnected.is_set()) + + def my_id(self): + iface = self.iface + try: + info = iface.getMyNodeInfo() if iface else None + return (info or {}).get("user", {}).get("id") + except Exception: + return None + + def node_name(self, node_id): + iface = self.iface + try: + user = (iface.nodes or {}).get(node_id, {}).get("user", {}) if iface else {} + return user.get("longName") or user.get("shortName") or node_id + except Exception: + return node_id + + def status(self): + iface = self.iface + out = {"connected": self.connected(), "device": self.device, "error": None, + "me": None, "nodes": [], "maxBytes": MAX_BYTES, "cooldown": COOLDOWN} + if not out["connected"]: + out["error"] = self.error + return out + try: + info = iface.getMyNodeInfo() or {} + user = info.get("user", {}) + out["me"] = {"id": user.get("id"), "longName": user.get("longName"), + "shortName": user.get("shortName")} + now = time.time() + for node_id, node in list((iface.nodes or {}).items()): + if node_id == out["me"]["id"]: + continue + u = node.get("user", {}) + metrics = node.get("deviceMetrics", {}) + out["nodes"].append({ + "id": node_id, + "longName": u.get("longName") or node_id, + "shortName": u.get("shortName"), + "lastHeard": node.get("lastHeard"), + "ago": int(now - node["lastHeard"]) if node.get("lastHeard") else None, + "snr": node.get("snr"), + "hops": node.get("hopsAway"), + "battery": metrics.get("batteryLevel"), + }) + out["nodes"].sort(key=lambda n: -(n["lastHeard"] or 0)) + except Exception as exc: + out["error"] = str(exc) + return out + + def send(self, text): + """Metni mesh'e yayınla / broadcast text to the mesh. Returns stored row id.""" + iface = self.iface + if not self.connected(): + raise RuntimeError("Radyo bağlı değil / radio not connected") + my_id = self.my_id() + row_id = self.store.add(packet_id=None, ts=time.time(), direction="out", from_id=my_id, + from_name=None, to_id="^all", channel=CHANNEL, text=text, + snr=None, rssi=None, hops=None, status="queued") + def on_ack(packet): + # Yayın mesajlarında cihaz, başka bir düğümün mesajı tekrar yayınladığını + # duyunca "örtük onay" verir. Kimse duymazsa MAX_RETRANSMIT hatası gelir. + # For broadcasts the node reports an implicit ACK once it hears another + # node rebroadcast the message; if nobody does, it reports MAX_RETRANSMIT. + routing = packet.get("decoded", {}).get("routing", {}) + reason = routing.get("errorReason", "NONE") + if reason == "NONE": + self.store.set_status(row_id, "relayed") + elif reason == "MAX_RETRANSMIT": + self.store.set_status(row_id, "unheard") + else: + self.store.set_status(row_id, "failed") + + from meshtastic.protobuf import portnums_pb2 + + with self.lock: + pkt = iface.sendData( + text.encode("utf-8"), + portNum=portnums_pb2.PortNum.TEXT_MESSAGE_APP, + wantAck=True, + onResponse=on_ack, + onResponseAckPermitted=True, + channelIndex=CHANNEL, + ) + self.store.mark_sent(row_id, getattr(pkt, "id", None)) + return row_id + + # bağlantı döngüsü / connection loop ------------------------------------- + def _open(self, path): + if self._factory: + return self._factory(path) + import meshtastic.serial_interface + + return meshtastic.serial_interface.SerialInterface(devPath=path, timeout=60) + + def run(self): + from pubsub import pub + + pub.subscribe(self.on_text, "meshtastic.receive.text") + pub.subscribe(self.on_lost, "meshtastic.connection.lost") + while True: + path = find_device() + if not path: + self.error = "Meshtastic cihazı bulunamadı, USB'ye takılı mı? / no Meshtastic device found" + time.sleep(RETRY_SECONDS) + continue + try: + log.info("Bağlanılıyor / connecting: %s", path) + self.lost.clear() + iface = self._open(path) + self.iface, self.device, self.error = iface, path, None + log.info("Bağlandı / connected: %s (%s)", path, self.my_id()) + while not self.lost.wait(5): + if not os.path.exists(path): # USB çıkarıldı / unplugged + break + except BaseException as exc: # meshtastic bazen SystemExit atar / may raise SystemExit + if isinstance(exc, KeyboardInterrupt): + raise + self.error = f"Bağlantı hatası / connection error: {exc}" + log.warning(self.error) + finally: + iface, self.iface = self.iface, None + if iface: + try: + iface.close() + except Exception: + pass + time.sleep(RETRY_SECONDS) + + +# ------------------------------------------------------------------------ http +class Handler(BaseHTTPRequestHandler): + server_version = "HASMeshChat/1.0" + radio = None + store = None + last_send = {} + last_send_lock = threading.Lock() + + def log_message(self, fmt, *args): + log.debug("%s %s", self.client_ip(), fmt % args) + + def client_ip(self): + ip = self.client_address[0] + # nginx arkasındayız; X-Real-IP'ye sadece yerelden gelirse güven. + # Trust X-Real-IP only when the request comes from local nginx. + if ip in ("127.0.0.1", "::1"): + ip = self.headers.get("X-Real-IP", ip) + return ip + + def send_json(self, data, code=HTTPStatus.OK): + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + url = urlparse(self.path) + if url.path in ("/", "/index.html"): + with open(os.path.join(STATIC_DIR, "index.html"), "rb") as f: + body = f.read() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + elif url.path == "/api/status": + self.send_json(self.radio.status()) + elif url.path == "/api/messages": + q = parse_qs(url.query) + try: + after = max(0, int(q.get("after", ["0"])[0])) + limit = min(500, max(1, int(q.get("limit", ["200"])[0]))) + watch = [int(x) for x in q.get("watch", [""])[0].split(",") if x][:50] + except ValueError: + return self.send_json({"error": "bad query"}, HTTPStatus.BAD_REQUEST) + self.send_json({"messages": self.store.since(after, limit), + "statuses": self.store.updated_statuses(watch)}) + else: + self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + + def do_POST(self): + if urlparse(self.path).path != "/api/send": + return self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + length = int(self.headers.get("Content-Length") or 0) + if length <= 0 or length > 4096: + return self.send_json({"error": "Geçersiz istek / bad request"}, HTTPStatus.BAD_REQUEST) + try: + data = json.loads(self.rfile.read(length)) + nick = clean_text(data.get("name", ""), MAX_NICK) + text = clean_text(data.get("text", ""), 1000) + except (ValueError, AttributeError): + return self.send_json({"error": "Geçersiz istek / bad request"}, HTTPStatus.BAD_REQUEST) + if not text: + return self.send_json({"error": "Mesaj boş / empty message"}, HTTPStatus.BAD_REQUEST) + full = f"{nick}: {text}" if nick else text + if len(full.encode("utf-8")) > MAX_BYTES: + return self.send_json({"error": f"Mesaj çok uzun, en fazla {MAX_BYTES} bayt / too long"}, + HTTPStatus.REQUEST_ENTITY_TOO_LARGE) + + ip = self.client_ip() + with self.last_send_lock: + wait = COOLDOWN - (time.monotonic() - self.last_send.get(ip, -1e9)) + if wait > 0: + return self.send_json({"error": f"Biraz bekleyin ({wait:.0f} sn) / please wait", + "retryAfter": wait}, HTTPStatus.TOO_MANY_REQUESTS) + self.last_send[ip] = time.monotonic() + try: + row_id = self.radio.send(full) + except Exception as exc: + with self.last_send_lock: + self.last_send.pop(ip, None) + return self.send_json({"error": str(exc)}, HTTPStatus.SERVICE_UNAVAILABLE) + log.info("gönderildi / sent from %s: %r", ip, full) + self.send_json({"ok": True, "id": row_id}) + + +def main(): + logging.basicConfig(level=os.getenv("MESHCHAT_LOG", "INFO"), + format="%(asctime)s %(levelname)s %(message)s") + store = Store(DB_PATH) + radio = Radio(store) + threading.Thread(target=radio.run, name="radio", daemon=True).start() + Handler.radio, Handler.store = radio, store + server = ThreadingHTTPServer((HOST, PORT), Handler) + server.daemon_threads = True + log.info("Mesh sohbet hazır / mesh chat ready: http://%s:%d/", HOST, PORT) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/mesh/requirements.txt b/mesh/requirements.txt new file mode 100644 index 0000000..e12949d --- /dev/null +++ b/mesh/requirements.txt @@ -0,0 +1 @@ +meshtastic>=2.7,<3 diff --git a/mesh/static/index.html b/mesh/static/index.html new file mode 100644 index 0000000..4887019 --- /dev/null +++ b/mesh/static/index.html @@ -0,0 +1,310 @@ + + + + + +HAS_PrepperDisk :: Mesh Sohbet + + + +
+ +
+
+ +
LoRa mesh üzerinden internetsiz mesajlaşma
+
+ +
+ + + + +
Radyo durumu
Kontrol ediliyor…
+ + + + + +
Genel kanal
Yükleniyor…
+
+
+ + Ad, mesajın başına eklenir. Herkes aynı radyoyu kullanır. +
+
+ + +
+
+ + 0 / 200 bayt +
+
+
+ + + + +
Ağdaki düğümler
+
Düğüm yok +
+
+
+ +
+ Mesajlar herkese açık genel kanala gider ve menzildeki tüm Meshtastic cihazlarında görünür. + LoRa yavaştır: kısa yaz, aynı mesajı tekrar gönderme. Cihaza özel gelen mesajlar + turuncu çizgiyle gösterilir ve bu sayfadaki herkes onları görebilir. +
+ +
HAS_PrepperDisk · Meschy / Meshtastic köprüsü
+
+ + + + diff --git a/scripts/install-meshchat.sh b/scripts/install-meshchat.sh new file mode 100755 index 0000000..8310adf --- /dev/null +++ b/scripts/install-meshchat.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Mesh sohbet köprüsünü kurar: USB'ye takılı Meschy / Meshtastic cihazını +# http://box.lan/mesh/ adresinde tarayıcıdan kullanılabilir yapar. +# Installs the mesh chat bridge: makes a USB-attached Meschy / Meshtastic node +# usable from a browser at http://box.lan/mesh/. +# +# İnternet gerekir (meshtastic Python paketi indirilir). Tekrar çalıştırmak güvenlidir. +# Needs internet (downloads the meshtastic Python package). Safe to re-run. +# +# Kullanım / usage: sudo ./scripts/install-meshchat.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$REPO/mesh" +APP=/opt/has-meshchat +DATA=/library/meshchat +NGINX_CONF=/etc/nginx/conf.d/meshchat-nginx.conf +DEFAULTS=/etc/default/has-meshchat + +say(){ printf '\n\033[1m== %s\033[0m\n' "$*"; } +die(){ echo "HATA / ERROR: $*" >&2; exit 1; } +[ "$EUID" -eq 0 ] || die "sudo ile çalıştır / run with sudo: sudo ./scripts/install-meshchat.sh" +[ -d /etc/nginx/conf.d ] || die "nginx bulunamadı. Önce / install first: sudo ./install.sh" + +say "1/5 Paketler / packages" +apt-get install -y python3-venv >/dev/null +id meshchat >/dev/null 2>&1 || useradd --system --home-dir "$DATA" --no-create-home --shell /usr/sbin/nologin meshchat +usermod -aG dialout meshchat + +say "2/5 Uygulama / application -> $APP" +install -d -m 755 "$APP" "$APP/static" +install -m 644 "$SRC/meshchat.py" "$SRC/requirements.txt" "$APP/" +install -m 644 "$SRC/static/index.html" "$APP/static/" +if [ ! -x "$APP/venv/bin/python" ]; then + python3 -m venv "$APP/venv" +fi +"$APP/venv/bin/pip" install --quiet --upgrade pip +"$APP/venv/bin/pip" install --quiet -r "$APP/requirements.txt" \ + || die "meshtastic indirilemedi. İnternet bağlantısını kontrol et / could not download meshtastic, check the internet connection" + +say "3/5 Veri klasörü ve ayarlar / data folder and settings" +install -d -m 750 -o meshchat -g meshchat "$DATA" +if [ ! -f "$DEFAULTS" ]; then + install -m 644 "$SRC/has-meshchat.default" "$DEFAULTS" + echo "Ayarlar / settings: $DEFAULTS" +else + echo "Mevcut ayarlar korundu / kept existing settings: $DEFAULTS" +fi + +say "4/5 Servis / service" +install -m 644 "$SRC/has-meshchat.service" /etc/systemd/system/has-meshchat.service +systemctl daemon-reload +systemctl enable has-meshchat >/dev/null +systemctl restart has-meshchat + +say "5/5 nginx (/mesh/)" +install -m 644 "$SRC/meshchat-nginx.conf" "$NGINX_CONF" +if nginx -t 2>/dev/null; then + systemctl reload nginx +else + rm -f "$NGINX_CONF" + nginx -t || true + die "nginx ayarı hatalı, geri alındı / nginx config failed and was removed" +fi + +# Ana sayfadaki "Mesh Sohbet" bölümü için güncel ana sayfayı kur. +# Install the current homepage so it shows the "Mesh Sohbet" entry. +if [ -d /library/www/html/home ]; then + "$REPO/scripts/install-homepage.sh" +fi + +# ModemManager, Meshtastic seri portunu kurcalayıp bağlantıyı bozabilir. +# ModemManager may probe the Meshtastic serial port and break the connection. +if systemctl is-active --quiet ModemManager 2>/dev/null; then + echo + echo "Uyarı / warning: ModemManager çalışıyor. Bağlantı sorunu olursa / is running. If the radio keeps disconnecting:" + echo " sudo systemctl disable --now ModemManager" +fi + +sleep 3 +say "Bitti / done" +DEVS=$(shopt -s nullglob; echo /dev/ttyACM* /dev/ttyUSB*) +if [ -n "$DEVS" ]; then + echo "Bulunan seri cihazlar / serial devices found: $DEVS" +else + echo "Henüz seri cihaz yok. Meschy'yi USB 2.0 (siyah) porta tak / no serial device yet, plug Meschy into a black USB 2.0 port" +fi +cat <Çevrimdışı bilgi arşivi — internet olmasa da açık @@ -125,6 +125,7 @@ zim: Kiwix dosya adının tarihsiz başı. Sayfa, cihazda hangi sürümün yüklü olduğunu Kiwix kataloğundan kendisi bulur. url: zim olmayan servisler için sabit adres. + mesh: mesh sohbet servisi; kurulu değilse gri görünür (scripts/install-meshchat.sh). Cihazda bulunamayan ZIM'ler gri klasörle "henüz yüklenmedi" görünür. */ const CATS = [ { title:"Ansiklopedi", items:[ @@ -134,6 +135,9 @@ { title:"Haritalar ve Navigasyon", items:[ { name:"Dünya Haritası", desc:"Avrupa ve Orta Doğu sokak seviyesinde, dünyanın geri kalanı şehir seviyesinde.", lang:"—", size:"~40 GB", url:"/maps" }, ]}, + { title:"Haberleşme", items:[ + { name:"Mesh Sohbet", desc:"Meschy / Meshtastic LoRa ağı üzerinden internetsiz mesajlaşma.", lang:"TR", size:"—", url:"/mesh/", mesh:true }, + ]}, { title:"Hayatta Kalma ve Hazırlık", items:[ { name:"Afet Sonrası Kitaplığı", desc:"Barınak, enerji, tarım ve yeniden yapılanma üzerine kitaplar.", lang:"EN", size:"~615 MB", zim:"zimgit-post-disaster_en", search:true }, { name:"Su Arıtma", desc:"İçme suyu bulma, arıtma ve saklama.", lang:"EN", size:"~20 MB", zim:"zimgit-water_en" }, @@ -184,7 +188,15 @@ }catch(e){ return null; } } -function render(books){ +/* Mesh sohbet servisi kurulu mu, radyo bağlı mı? / is mesh chat installed, radio connected? */ +async function loadMesh(){ + try{ + const r = await fetch("/mesh/api/status", {cache:"no-store"}); + return r.ok ? await r.json() : null; + }catch(e){ return null; } +} + +function render(books, mesh){ let html = "", ready = 0, total = 0; const used = new Set(); const sel = document.getElementById("book"); @@ -193,6 +205,10 @@ for (const i of c.items) { total++; let url = i.url || null, status = i.url ? "Kurulu" : "Henüz yüklenmedi"; + if (i.mesh) { + if (!mesh) { url = null; status = "Henüz kurulmadı"; } + else status = mesh.connected ? `Radyo bağlı, ${mesh.nodes.length} düğüm` : "Radyo bağlı değil"; + } if (i.zim) { if (books === null) { url = "/kiwix/"; status = "Kütüphaneden açın"; } else { @@ -251,7 +267,7 @@ : `/kiwix/search?pattern=${encodeURIComponent(q)}`; }); -loadBooks().then(render); +Promise.all([loadBooks(), loadMesh()]).then(([books, mesh]) => render(books, mesh)); From 4ab2eef91d0f68ca3211e76acca51cbee934fe71 Mon Sep 17 00:00:00 2001 From: volthdl Date: Tue, 3 Nov 2026 00:39:26 +0300 Subject: [PATCH 2/3] Update requirements.txt --- mesh/requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mesh/requirements.txt b/mesh/requirements.txt index e12949d..c361351 100644 --- a/mesh/requirements.txt +++ b/mesh/requirements.txt @@ -1 +1,3 @@ -meshtastic>=2.7,<3 + # Test edilen sürümler / tested versions (2026-11-01) + meshtastic==2.7.11 + pypubsub==4.0.7 From a1e2f52772e9eab5e09766ddbffe397f5852976c Mon Sep 17 00:00:00 2001 From: volthdl Date: Tue, 3 Nov 2026 00:42:28 +0300 Subject: [PATCH 3/3] Pin meshtastic and pypubsub to tested versions