diff --git a/.gitignore b/.gitignore index fe92647..d9dd814 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ wheels/ # Virtual environments .venv + +# SQLite Database +*.db diff --git a/components/data/ChatDB.py b/components/data/ChatDB.py new file mode 100644 index 0000000..cd1e801 --- /dev/null +++ b/components/data/ChatDB.py @@ -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() diff --git a/components/data/__init__.py b/components/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/mesh_conn/MeshConn.py b/components/mesh_conn/MeshConn.py new file mode 100644 index 0000000..934679d --- /dev/null +++ b/components/mesh_conn/MeshConn.py @@ -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) + diff --git a/components/mesh_conn/__init__.py b/components/mesh_conn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/tui/chat_window/ChatWindow.py b/components/tui/chat_window/ChatWindow.py new file mode 100644 index 0000000..75e1eae --- /dev/null +++ b/components/tui/chat_window/ChatWindow.py @@ -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) + \ No newline at end of file diff --git a/components/tui/chat_window/__init__.py b/components/tui/chat_window/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/tui/mesh_app/MeshApp.py b/components/tui/mesh_app/MeshApp.py new file mode 100644 index 0000000..b0a5d71 --- /dev/null +++ b/components/tui/mesh_app/MeshApp.py @@ -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) diff --git a/components/tui/mesh_app/__init__.py b/components/tui/mesh_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py index 4c94af3..12db4e8 100644 --- a/main.py +++ b/main.py @@ -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()