Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ wheels/

# Virtual environments
.venv

# SQLite Database
*.db
37 changes: 37 additions & 0 deletions components/data/ChatDB.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import sqlite3
from datetime import datetime

class ChatDB:
def __init__(self, db_path="chat_history.db"):
self.conn = sqlite3.connect(db_path)
self.create_table()

def create_table(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact TEXT NOT NULL,
sender TEXT NOT NULL,
message TEXT NOT NULL,
timestamp TEXT NOT NULL
)
""")
self.conn.commit()

def save_message(self, contact, sender, message):
timestamp = datetime.now().isoformat()
self.conn.execute(
"INSERT INTO messages (contact, sender, message, timestamp) VALUES (?, ?, ?, ?)",
(contact, sender, message, timestamp)
)
self.conn.commit()

def load_messages(self, contact):
cursor = self.conn.execute(
"SELECT sender, message, timestamp FROM messages WHERE contact = ? ORDER BY timestamp",
(contact,)
)
return cursor.fetchall()

def close(self):
self.conn.close()
Empty file added components/data/__init__.py
Empty file.
71 changes: 71 additions & 0 deletions components/mesh_conn/MeshConn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# api for meshtastic cli
import meshtastic
import meshtastic.serial_interface
from pubsub import pub
import time
import sys


class MeshConn:
def __init__(self, on_message_callback=None):
"""
Connect to a meshtastic radio through a serial port.
"""
self.on_message_callback = on_message_callback
pub.subscribe(self.on_receive, "meshtastic.receive")

try:
self.interface = meshtastic.serial_interface.SerialInterface()
except Exception as e:
print("ERROR: There was an error connecting to the radio.", e)
sys.exit(1)

try:
self.nodes = self.interface.nodes.items()
except AttributeError as e:
print("ERROR: It is likely that there is no radio connected.", e)
sys.exit(1)

self.this_node = self.interface.getMyNodeInfo()

# create dictionary for long names and radio ids {"longName":"id"}
self.name_id_map = { metadata['user']['longName'] : id for id, metadata in self.nodes}

# another one for the reverse {"id": "longName"}
self.id_name_map = { id : metadata['user']['longName'] for id, metadata in self.nodes}

def get_this_node(self):
"""
Return the name of our radio.
"""
return self.this_node['user']['longName']

def on_receive(self, packet):
"""
Receive a message sent to our radio.
"""
try:
to_id = packet.get("toId")
from_id = packet.get("fromId")
message = packet["decoded"]["text"]
this_id = self.this_node['user']['id']

if to_id == this_id or to_id == "^all":
sender_name = self.id_name_map.get(from_id, "Unknown")
if self.on_message_callback:
self.on_message_callback(sender_name, message)
except Exception as e:
print(f"[ERROR on_receive] {e}")


def get_long_names(self):
"""
Retrieve the names of all other nodes in the mesh
"""
long_names = self.name_id_map.keys()
return long_names

async def send_message(self, longname: str, message: str):
id = self.name_id_map[longname]
self.interface.sendText(message, destinationId=id)

Empty file.
41 changes: 41 additions & 0 deletions components/tui/chat_window/ChatWindow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from textual.widgets import Static, Input
from textual.containers import Vertical, VerticalScroll
from textual.app import ComposeResult
from datetime import datetime


class ChatWindow(Vertical):
def __init__(self, longname: str, send_callback=None, **kwargs):
super().__init__(**kwargs)
self.longname = longname
self.messages = []
self.send_callback = send_callback

self.message_scroll = VerticalScroll()
self.message_scroll.styles.width = "100%"

self.message_display = Static()

self.input_box = Input(placeholder="Type a message and press Enter...")

def compose(self) -> ComposeResult:
yield self.message_scroll
yield self.input_box

async def on_mount(self):
await self.message_scroll.mount(self.message_display)

def on_input_submitted(self, event: Input.Submitted) -> None:
message = event.value.strip()
if message:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.messages.append(f" [dim]{timestamp}[/dim]\n[blue] You[/blue]: {message}\n")
self.update_display()
self.input_box.value = ""
if self.send_callback and self.longname:
self.app.call_later(self.send_callback, self.longname, message)

def update_display(self):
self.message_display.update("\n".join(self.messages))
self.message_scroll.scroll_end(animate=False)

Empty file.
76 changes: 76 additions & 0 deletions components/tui/mesh_app/MeshApp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from textual.widgets import Header, Footer, ListView, ListItem, Label, Static
from textual.containers import Horizontal
from textual.app import App, ComposeResult

from components.data.ChatDB import ChatDB
from components.mesh_conn.MeshConn import MeshConn
from components.tui.chat_window.ChatWindow import ChatWindow
from datetime import datetime


class MeshApp(App):
"""
Main app.
"""

BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

def __init__(self):
super().__init__()
self.db = ChatDB()
self.conn = MeshConn(on_message_callback=self.handle_incoming_message)
self.chat_window = ChatWindow("Select a contact", send_callback=self.send_message)
self.contact_list = ListView()
self.connection_label = Static()

def handle_incoming_message(self, longname, message):
self.call_later(self._display_incoming_message, longname, message)

async def _display_incoming_message(self, longname, message):
if self.chat_window.longname == longname:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.chat_window.messages.append(f" [dim]{timestamp}[/dim]\n[red] {longname}[/red]: {message}\n")
self.chat_window.update_display()
self.db.save_message(longname, longname, message)

def compose(self) -> ComposeResult:
yield Header()
self.contact_list.styles.width = "35%"
self.chat_window.styles.width = "65%"
yield self.connection_label
with Horizontal():
yield self.contact_list
yield self.chat_window
yield Footer()

async def on_mount(self) -> None:
node_name = self.conn.get_this_node()
self.connection_label.update(f"[bold green]Connected to:[/] {node_name}")

for name in self.conn.get_long_names():
await self.contact_list.append(ListItem(Label(name)))

def action_toggle_dark(self) -> None:
self.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)

async def on_list_view_selected(self, message: ListView.Selected) -> None:
selected_label = message.item.query_one(Label)
name = str(selected_label.renderable)
self.chat_window.longname = name
self.chat_window.input_box.border_title = f"Chat with {name}"
self.chat_window.messages = []

for sender, msg, timestamp in self.db.load_messages(name):
timestamp = timestamp[0:10] + " " + timestamp[11:19]
if sender == "You":
self.chat_window.messages.append(f" [dim]{timestamp}[/dim]\n[blue] You[/blue]: {msg}\n")
else:
self.chat_window.messages.append(f" [dim]{timestamp}[/dim]\n[red] {sender}[/red]: {message}\n")

self.chat_window.update_display()

async def send_message(self, longname: str, message: str):
await self.conn.send_message(longname, message)
self.db.save_message(longname, "You", message)
Empty file.
23 changes: 2 additions & 21 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,5 @@
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header


class ExampleMeshApp(App):
"""A Textual app to manage stopwatches."""

BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
yield Footer()

def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)

from components.tui.mesh_app.MeshApp import MeshApp

if __name__ == "__main__":
app = ExampleMeshApp()
app = MeshApp()
app.run()
Loading