From d22c263f6fdff74f8b39a40dce12b6dafca62bbb Mon Sep 17 00:00:00 2001 From: foamyguy Date: Tue, 29 Jul 2025 09:37:52 -0500 Subject: [PATCH 1/4] fruit jam IRC app --- Fruit_Jam/Fruit_Jam_IRC_Client/beep.wav | Bin 0 -> 4844 bytes Fruit_Jam/Fruit_Jam_IRC_Client/code.py | 75 +++ .../Fruit_Jam_IRC_Client/curses_irc_client.py | 248 +++++++++ Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py | 490 ++++++++++++++++++ 4 files changed, 813 insertions(+) create mode 100644 Fruit_Jam/Fruit_Jam_IRC_Client/beep.wav create mode 100644 Fruit_Jam/Fruit_Jam_IRC_Client/code.py create mode 100644 Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py create mode 100644 Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/beep.wav b/Fruit_Jam/Fruit_Jam_IRC_Client/beep.wav new file mode 100644 index 0000000000000000000000000000000000000000..65ec415b412da6361ca6ae3e86ae01eae35ee305 GIT binary patch literal 4844 zcmb7?Npj;h6o%EiEW2`ooFr9QbXR4YMY8UyR5r0?jTA*u0whkNMh|_U93}_J{~qu_ z02XbBvO#|MhX>03?bokg|NU_=_~Vz~zx;JS`|0ywF!+Rz{&~Q^p9Vh+{`@xk_76O| zxx9RTxioq)zWe)boH|YoE76JXS%3cid}&lVzV}&29P7g9dzF7dALZ}RPrA=I5BLmy z0)2w>otae+tLFUl^2Gk(-H`nOzo(-=@Do3y5A%liwvQO+2{_h;X2;r?NY_c=X2 zKb{)BoDwfp&0MR9W1V;izlQ(m@x=aHOntsC9OvKQ>(iC{N5c`%V}_p@eF5V<*T?Iz z*2~%GVKl?}jsdG4Rv+|BU*NwS@&2>%!+3`8?|69xV?Hzd%%qR3eziI=={{4QSY_3C}RY7qYgjJbey7@d19)iIHy)EpswY`WgL!u5#vF7o0D&WfigL;uqcLwrx0{ zBq{f`Z4F)~NwNgjt>s0sVBWX;x={I&Srs)i`~i!O{7rwXOFs4!?=g?`g`Mv+%_pWl zt2g_G{plj*^D?VE182D^DsTnAVQpDQdLR4#)b=&D54_g9ssZoRI!k@l5kp_5OZL}I z&HjMlXI8t4bz$@eexsL|w}OAQR=cvU>vE^T%Qu+(q-*OJj`Pr~y5hcUnQ{OBVn3fD zPh8o)w>i(UEXVs4@E3R=!`ia4%zDZCDqFF?tiaHTH+owTZ`CGbZCODtp%bsHpP2kU ziyr%hKj6<-dA@R<)C39y|27oPbtd|BQJ-_Hucbhfl zt6-r6(T@4VDsTG+G1A@q*r)Er+zCvlpKvA)B zUz5Bp5uTKq$i-P&f?gj?yIADTej~BDYj9{w>@BF_(KO!sxJM)MAm0JqSy<$-X)ua7 ztc`l?Wn5UzPtfBuiOELCkss)G_@P_)TeW{`cKJs4Z1WQH_Ty%AmcR%I0&-k8)wKs< z0?K)t=FwltBkgkcbT2EaX{BPb!wrst2kw@fSH&jIkthZ{Mqnp+o$N79vbs1IIWjUg zx{!!2N)LQ_U!~dM)pbZf?yivwcri89XH4#HGxp|^d|FP1ej03kZ52~c%0Tnt&X6f+ zyI%=iw=?j KY&2SNDg7U9?_k#e literal 0 HcmV?d00001 diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/code.py b/Fruit_Jam/Fruit_Jam_IRC_Client/code.py new file mode 100644 index 000000000..ab43dcfb4 --- /dev/null +++ b/Fruit_Jam/Fruit_Jam_IRC_Client/code.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT +from displayio import Group +from terminalio import FONT +import supervisor +from os import getenv +import audiocore +import board +import busio +from digitalio import DigitalInOut +from adafruit_esp32spi import adafruit_esp32spi +from adafruit_color_terminal import ColorTerminal +from adafruit_fruitjam.peripherals import Peripherals + +from curses_irc_client import run_irc_client + +# Configuration - modify these values as needed +IRC_CONFIG = { + "server": "irc.libera.chat", # Example: irc.libera.chat, irc.freenode.net + # "port": 6667, # 6667 - clear text + "port": 6697, # 6697 - TLS encrypted + "username": "", + "channel": "#adafruit-fruit-jam", +} + +if IRC_CONFIG["username"] == "": + raise ValueError("username must be set in IRC_CONFIG") + +main_group = Group() +display = supervisor.runtime.display + +font_bb = FONT.get_bounding_box() +screen_size = (display.width // font_bb[0], display.height // font_bb[1]) + +terminal = ColorTerminal(FONT, screen_size[0], screen_size[1]) +main_group.append(terminal.tilegrid) +display.root_group = main_group + +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") + +# If you are using a board with pre-defined ESP32 Pins: +esp32_cs = DigitalInOut(board.ESP_CS) +esp32_ready = DigitalInOut(board.ESP_BUSY) +esp32_reset = DigitalInOut(board.ESP_RESET) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +print("Connecting to AP...") +while not esp.is_connected: + try: + esp.connect_AP(ssid, password) + except RuntimeError as e: + print("could not connect to AP, retrying: ", e) + continue + +print(f"IRC Configuration:") +print(f"Server: {IRC_CONFIG['server']}:{IRC_CONFIG['port']}") +print(f"Nickname: {IRC_CONFIG['username']}") +print(f"Channel: {IRC_CONFIG['channel']}") +print("-" * 40) + +fruit_jam_peripherals = Peripherals() +beep_wave = audiocore.WaveFile("beep.wav") +run_irc_client( + esp, + IRC_CONFIG, + terminal, + terminal.tilegrid, + audio_interface=fruit_jam_peripherals.audio, + beep_wave=beep_wave, +) diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py b/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py new file mode 100644 index 000000000..09397b55d --- /dev/null +++ b/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py @@ -0,0 +1,248 @@ +# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT +import adafruit_dang as curses +import time + +from irc_client import IRCClient + +ANSI_BLACK_ON_GREY = chr(27) + "[30;100m" +ANSI_RESET = chr(27) + "[0m" + + +class Window: + """ + Terminal Window class that supports basic scrolling. + """ + + def __init__(self, n_rows, n_cols, row=0, col=0): + self.n_rows = n_rows + self.n_cols = n_cols + self.row = row + self.col = col + + @property + def bottom(self): + return self.row + self.n_rows - 1 + + def up(self, cursor): # pylint: disable=invalid-name + if cursor.row == self.row - 1 and self.row > 0: + self.row -= 1 + + def down(self, buffer, cursor): + if cursor.row == self.bottom + 1 and self.bottom < len(buffer) - 1: + self.row += 1 + + def horizontal_scroll(self, cursor, left_margin=5, right_margin=2): + n_pages = cursor.col // (self.n_cols - right_margin) + self.col = max(n_pages * self.n_cols - right_margin - left_margin, 0) + + def translate(self, cursor): + return cursor.row - self.row, cursor.col - self.col + + +def irc_client_main( + stdscr, + radio, + irc_config, + terminal_tilegrid=None, + audio_interface=None, + beep_wave=None, +): + """ + Main curses IRC client application loop. + """ + irc_client = IRCClient( + radio, irc_config, audio_interface=audio_interface, beep_wave=beep_wave + ) + irc_client.connect() + # irc_client.join() + + window = Window(terminal_tilegrid.height, terminal_tilegrid.width) + stdscr.erase() + img = [None] * window.n_rows + status_bar = { + "user_message": None, + "user_message_shown_time": 0, + } + + cur_row_index = 0 + + user_input = "" + + def show_user_message(message): + """ + Show a status message to the user + """ + status_bar["user_message"] = message + ( + " " * (window.n_cols - 1 - len(message)) + ) + status_bar["user_message_shown_time"] = time.monotonic() + + def setline(row, line): + """ + Set a line of text in the terminal window. + """ + if img[row] == line: + return + img[row] = line + line += " " * (window.n_cols - len(line) - 1) + stdscr.addstr(row, 0, line) + + def get_page(row_index): + """ + Get a page of messages from the message buffer. + """ + page_len = window.n_rows - 2 + + page_start = max((len(irc_client.message_buffer) + row_index) - page_len, 0) + page_end = page_start + page_len + + page = irc_client.message_buffer[page_start:page_end] + # print(f"get_page({row_index}) len: {len(page)} start: {page_start} end: {page_end} rows: {window.n_rows - 2}") + return page + + try: + # main application loop + while True: + lastrow = 0 + lines_added = irc_client.update() + + cur_page = get_page(cur_row_index) + + if lines_added > 0 and len(cur_page) < window.n_rows - 2: + cur_row_index = max(cur_row_index - lines_added, 0) + cur_page = get_page(cur_row_index) + + for row, line in enumerate(cur_page): + lastrow = row + setline(row, line) + + for row in range(lastrow + 1, window.n_rows - 2): + setline(row, "") + + user_input_row = window.n_rows - 2 + if user_input: + setline(user_input_row, user_input) + else: + setline(user_input_row, " " * (window.n_cols - 1)) + + user_message_row = terminal_tilegrid.height - 1 + if status_bar["user_message"] is None: + message = f" {irc_config['username']} | {irc_config['server']} | {irc_config['channel']}" + message += " " * (terminal_tilegrid.width - len(message) - 1) + line = f"{ANSI_BLACK_ON_GREY}{message}{ANSI_RESET}" + else: + line = f"{ANSI_BLACK_ON_GREY}{status_bar["user_message"]}{ANSI_RESET}" + if status_bar["user_message_shown_time"] + 3.0 < time.monotonic(): + status_bar["user_message"] = None + setline(user_message_row, line) + + # read from the keyboard + k = stdscr.getkey() + if k is not None: + if len(k) == 1 and " " <= k <= "~": + user_input += k + + elif k == "\n": # enter key pressed + if not user_input.startswith("/"): + print(f"sending: {user_input}") + irc_client.send_message(user_input) + user_input = "" + else: # slash commands + parts = user_input.split(" ", 1) + if parts[0] in {"/j", "/join"}: + if len(parts) >= 2 and parts[1] != "": + if parts[1] != irc_client.config["channel"]: + irc_client.join(parts[1]) + user_input = "" + else: + show_user_message("Already in channel") + user_input = "" + + else: + show_user_message( + "Invalid /join arg. Use: /join " + ) + user_input = "" + elif parts[0] == "/msg": + to_user, message_to_send = parts[1].split(" ", 1) + irc_client.send_dm(to_user, message_to_send) + user_input = "" + elif parts[0] == "/beep": + to_user = parts[1] + message_to_send = "*Beep*\x07" + irc_client.send_dm(to_user, message_to_send) + user_input = "" + elif parts[0] == "/op": + user_to_op = parts[1] + irc_client.op(user_to_op) + user_input = "" + elif parts[0] == "/deop": + user_to_op = parts[1] + irc_client.deop(user_to_op) + user_input = "" + elif parts[0] == "/kick": + user_to_kick = parts[1] + irc_client.kick(user_to_kick) + user_input = "" + elif parts[0] == "/ban": + user_to_ban = parts[1] + irc_client.ban(user_to_ban) + user_input = "" + elif parts[0] == "/unban": + user_to_unban = parts[1] + irc_client.unban(user_to_unban) + user_input = "" + elif parts[0] == "/whois": + user_to_check = parts[1] + irc_client.whois(user_to_check) + user_input = "" + + elif k in ("KEY_BACKSPACE", "\x7f", "\x08"): + user_input = user_input[:-1] + elif k == "KEY_UP": + page_len = window.n_rows - 2 + if len(irc_client.message_buffer) > page_len: + page_start = ( + len(irc_client.message_buffer) + cur_row_index + ) - page_len + if page_start > 0: + cur_row_index -= 1 + elif k == "KEY_DOWN": + if cur_row_index < 0: + cur_row_index += 1 + + elif k == "KEY_PGUP": + page_len = window.n_rows - 2 + if len(irc_client.message_buffer) > page_len: + page_start = ( + len(irc_client.message_buffer) + cur_row_index + ) - page_len + if page_start > 0: + cur_row_index -= 6 + elif k == "KEY_PGDN": + if cur_row_index <= 0: + cur_row_index = cur_row_index + 6 + else: + print(f"unknown key: {k}") + + except KeyboardInterrupt: + irc_client.disconnect() + raise KeyboardInterrupt + + +def run_irc_client( + radio, irc_config, terminal, terminal_tilegrid, audio_interface=None, beep_wave=None +): + """ + Entry point to run the curses IRC client application. + """ + return curses.custom_terminal_wrapper( + terminal, + irc_client_main, + radio, + irc_config, + terminal_tilegrid, + audio_interface, + beep_wave, + ) diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py b/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py new file mode 100644 index 000000000..8e7e6c232 --- /dev/null +++ b/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py @@ -0,0 +1,490 @@ +# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT +import time + + +import adafruit_connection_manager + +ANSI_ESCAPE_CODES = [ + chr(27) + "[30m", + chr(27) + "[31m", + chr(27) + "[32m", + chr(27) + "[33m", + chr(27) + "[34m", + chr(27) + "[35m", + chr(27) + "[36m", +] +ANSI_RESET = chr(27) + "[0m" + + +class IRCClient: + """ + Handles interaction with IRC Server and makes incoming messages available. + + :param radio: The network radio to connect with. + :param dict irc_config: Dictionary containing IRC configration for server, port, username and channel. + :param audio_interface: Optional interface to play audio from for beep messages + :param beep_wave: Optional wave file to use for beep messages + :param int max_line_length: Maximum characters per line to format messages into. + """ + + def __init__( + self, + radio, + irc_config, + audio_interface=None, + beep_wave=None, + max_line_length=120, + ): + self.radio = radio + self.config = irc_config + required = {"username", "server", "channel"} + for key in required: + if key not in self.config: + raise ValueError( + f"missing required config key. Required keys are: {required}" + ) + + if "port" not in self.config: + self.config["port"] = 6667 + if "timeout" not in self.config: + self.config["timeout"] = 120 + + self.pool = adafruit_connection_manager.get_radio_socketpool(radio) + self.connection_manager = adafruit_connection_manager.get_connection_manager( + self.pool + ) + + ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) + print(f"Connecting to {self.config['server']}:{self.config['port']}...") + self.socket = self.connection_manager.get_socket( + self.config["server"], + self.config["port"], + "", + timeout=0.01, + is_ssl=True, + ssl_context=ssl_context, + ) + print("Connected") + + # color to use for next unique username + self.next_color_index = 4 + + # map of unique usernames to color + self.user_color_map = {} + + # buffer for incoming data until it's a full line + self.line_buffer = "" + + # buffer for full incoming chat messages + self.message_buffer = [] + + # whether to show whois reply message on screen + self.show_whois_reply = False + + self.audio_interface = audio_interface + if audio_interface is not None: + self.beep_wave = beep_wave + + self.max_line_length = max_line_length + + def connect(self): + """ + Connect to IRC Server + """ + # Send nick and user info + self.socket.send(f"NICK {self.config['username']}\r\n".encode("utf-8")) + self.socket.send( + f"USER {self.config['username']} 0 * :{self.config['username']}\r\n".encode( + "utf-8" + ) + ) + + def disconnect(self): + """ + Disconnect from IRC Server + """ + self.socket.send("QUIT :Goodbye\r\n".encode("utf-8")) + self.socket.close() + + def readlines(self): + """ + Read incoming data from the socket and return a list of lines read. + """ + lines = [] + # Receive data + data = self.socket.recv(4096).decode("utf-8") + if not data: + raise RuntimeError("Connection closed by server") + + self.line_buffer += data + + # Process complete lines + while "\r\n" in self.line_buffer: + line, self.line_buffer = self.line_buffer.split("\r\n", 1) + + if line: + lines.append(line) + + return lines + + def update(self): + """ + Check for udpates from the server. Main loop of the program should call this. + """ + updated_display_lines = 0 + try: + lines = self.readlines() + for line in lines: + updated_display_lines += self.process_message(line) + + except OSError as e: + # no data before timeout + # print(e) + if "ETIMEDOUT" not in str(e): + raise RuntimeError(e) + # raise RuntimeError("Connection timed out") + return updated_display_lines + + def send_message(self, message): + """ + Send a message to the channel that the user is in. + """ + irc_command = f"PRIVMSG {self.config['channel']} :{message}\r\n" + self.socket.send(irc_command.encode("utf-8")) + self.process_message( + f":{self.config['username']}!~{self.config['username']}@localhost " + + irc_command[:-2] + ) + + def send_dm(self, to_user, message): + """ + Send a direct message to a specified user. + """ + irc_command = f"PRIVMSG {to_user} :{message}\r\n" + self.socket.send(irc_command.encode("utf-8")) + # self.process_message(f":{self.config['username']}!~{self.config['username']}@localhost " + irc_command[:-2]) + color = self.get_color_for_user(to_user) + self.message_buffer.append(f"DM out: <{color}{to_user}{ANSI_RESET}> {message}") + + def op(self, user): + """ + Make specified user an operator in the channel that the user is in. + You must already be an operator to grant operator privilege. + """ + op_cmd = f"MODE {self.config['channel']} +o {user}\r\n" + self.socket.send(op_cmd.encode("utf-8")) + + def deop(self, user): + """ + Remove operator privilege from the specified user for this channel. + """ + deop_cmd = f"MODE {self.config['channel']} -o {user}\r\n" + self.socket.send(deop_cmd.encode("utf-8")) + + def kick(self, user): + """ + Kick a specified user from the channel. + """ + kick_cmd = f"KICK {self.config['channel']} {user}\r\n" + self.socket.send(kick_cmd.encode("utf-8")) + + def get_technical_name(self, nickname): + """ + Get the full technical name of a user given a nickname + """ + start_time = time.monotonic() + whois_cmd = f"WHOIS {nickname}\r\n" + self.socket.send(whois_cmd.encode("utf-8")) + + whois_resp_lines = None + while whois_resp_lines is None and start_time + 3.0 > time.monotonic(): + try: + whois_resp_lines = self.readlines() + except OSError as e: + if "ETIMEDOUT" in str(e): + whois_resp_lines = None + else: + raise RuntimeError(e) + + if whois_resp_lines is None: + return None + + for line in whois_resp_lines: + line = line.lstrip("\0") + parts = line.split(" ", 2) + if len(parts) >= 2: + command = parts[1] + if command != "311": + self.process_message(line) + continue + + whois_response = parts[2].split(" ", 1)[1] + response_parts = whois_response.split(" ") + showname = response_parts[0] + technical_name = f"*!{response_parts[1]}@{response_parts[2]}" + return technical_name + + def ban(self, user): + """ + Ban the specified user from the channel + """ + technical_name = self.get_technical_name(user) + if technical_name is not None: + ban_cmd = f"MODE {self.config['channel']} +b {technical_name}\r\n" + self.socket.send(ban_cmd.encode("utf-8")) + else: + self.message_buffer.append( + f"{ANSI_RESET} Error: failed whois lookup for ban" + ) + + def unban(self, user): + """ + Unban the specified user from the channel + """ + technical_name = self.get_technical_name(user) + if technical_name is not None: + ban_cmd = f"MODE {self.config['channel']} -b {technical_name}\r\n" + self.socket.send(ban_cmd.encode("utf-8")) + else: + self.message_buffer.append( + f"{ANSI_RESET} Error: failed whois lookup for unban" + ) + + def whois(self, user): + """ + Run a whois query on the specified user + """ + self.show_whois_reply = True + whois_cmd = f"WHOIS {user}\r\n" + self.socket.send(whois_cmd.encode("utf-8")) + + def leave_channel(self): + """ + Leave the channel + """ + + self.socket.send(f"PART {self.config['channel']}\r\n".encode("utf-8")) + + def join(self, new_channel=None): + """ + Join the specified channel. This will leave the prior channel. + """ + if new_channel is not None and new_channel != self.config["channel"]: + self.leave_channel() + self.config["channel"] = new_channel + + print(f"Joining channel {self.config['channel']}...") + self.socket.send(f"JOIN {self.config['channel']}\r\n".encode("utf-8")) + self.message_buffer.append(f"{ANSI_RESET}* Joined {self.config['channel']} *") + + def get_color_for_user(self, username): + """ + Get the color to use for the specified username + """ + if username not in self.user_color_map: + self.user_color_map[username] = self.next_color_index + self.next_color_index += 1 + if self.next_color_index > 6: + self.next_color_index = 1 + + return ANSI_ESCAPE_CODES[self.user_color_map[username]] + + @staticmethod + def split_string_chunks(s, chunk_size): + """ + Split a string into chunks of specified size. + """ + chunks = [] + for i in range(0, len(s), chunk_size): + chunks.append(s[i : i + chunk_size]) + return chunks + + def process_message(self, message): + """ + Process an incoming IRC message + :param message: The message that came from the IRC server. + + :return lines_added: The number of lines added to the display + """ + + lines_added = 0 + + message = message.lstrip("\x00") + print(f"RAW: {message.encode('utf-8')}") + + # Handle PING messages (keep connection alive) + if message.startswith("PING"): + pong_response = message.replace("PING", "PONG") + self.socket.send(f"{pong_response}\r\n".encode("utf-8")) + print(f"Responded to PING") + return 0 + + # Parse IRC message format: :prefix COMMAND params + parts = message.split(" ", 2) + + if len(parts) >= 2: + command = parts[1] + try: + command_num = int(command) + except ValueError: + command_num = None + + # End of MOTD - now we can join the channel + if command == "376" or command == "422": # 422 is "no MOTD" + # join channel + self.join() + + # Welcome messages (001-004 are standard welcome messages) + elif command in [ + "001", + "002", + "003", + "004", + "251", + "252", + "253", + "254", + "255", + "265", + "266", + "375", + "372", + ]: + if len(parts) >= 3: + welcome_text = parts[2] + if welcome_text.startswith(":"): + welcome_text = welcome_text[1:] + + print( + f"'{welcome_text[0:11]}' startswith '{self.config['username']}' ? {welcome_text.startswith(self.config['username'])}" + ) + if welcome_text.startswith(self.config["username"]): + welcome_text = welcome_text.replace( + self.config["username"], "", 1 + ) + # terminal.write(f"WELCOME: {welcome_text}\n") + self.message_buffer.append(f"{welcome_text}") + lines_added += 1 + print(f"WELCOME: {welcome_text}") + + # Channel messages + elif command == "PRIVMSG": + if len(parts) >= 3: + # Extract sender nickname + sender = parts[0] + if sender.startswith(":"): + sender = sender[1:] + if "!" in sender: + sender = sender.split("!")[0] + + # Extract message content + message_content = parts[2] + + inc_channel, inc_message = message_content.split(" ", 1) + + message_content = inc_message[1:] + + if "*beep*" in message_content: + if ( + self.audio_interface is not None + and not self.audio_interface.playing + ): + print("playing beep") + self.audio_interface.play(self.beep_wave) + # print(f"is playing: {self.audio_interface.playing}") + while self.audio_interface.playing: + pass + + print(f"message_content: {message_content.encode('utf-8')}") + + color = self.get_color_for_user(sender) + + if inc_channel == self.config["channel"]: + full_line = f"<{color}{sender}{ANSI_RESET}> {message_content}" + if len(full_line) < self.max_line_length: + self.message_buffer.append(full_line) + lines_added += 1 + else: + chunks = self.split_string_chunks( + full_line, self.max_line_length + ) + for chunk in chunks: + self.message_buffer.append(f"{ANSI_RESET}{chunk}") + lines_added += 1 + elif inc_channel == self.config["username"]: + self.message_buffer.append( + f"DM in: <{color}{sender}{ANSI_RESET}> {message_content}" + ) + lines_added += 1 + print(f"<{sender}> {message_content}") + + # Join confirmations + elif command == "JOIN": + sender = parts[0] + if sender.startswith(":"): + sender = sender[1:] + if "!" in sender: + sender = sender.split("!")[0] + + if len(parts) >= 3: + joined_channel = parts[2] + if joined_channel.startswith(":"): + joined_channel = joined_channel[1:] + print(f"*** {sender} joined {joined_channel}") + + # error messages + elif command_num is not None and 400 <= command_num <= 553: + # message codes: https://www.alien.net.au/irc/irc2numerics.html + self.message_buffer.append(f"{ANSI_RESET}{command} {parts[2]}") + lines_added += 1 + + # whois reply + elif self.show_whois_reply and command == "311": + whois_response = parts[2].split(" ", 1)[1] + self.message_buffer.append(f"{ANSI_RESET}{whois_response}") + lines_added += 1 + self.show_whois_reply = False + + # Mode messages + elif command == "MODE": + action_user = parts[0].split("!", 1)[0][1:] + mode_msg_parts = parts[2].split(" ", 2) + if len(mode_msg_parts) >= 3: + channel, mode, target_user = mode_msg_parts + action_user_color = self.get_color_for_user(action_user) + target_user_color = self.get_color_for_user(target_user) + self.message_buffer.append( + f"{action_user_color}{action_user}{ANSI_RESET} sets mode {mode} on {target_user_color}{target_user}{ANSI_RESET}" + ) + lines_added += 1 + + # Part messages + elif command == "PART": + sender = parts[0] + if sender.startswith(":"): + sender = sender[1:] + if "!" in sender: + sender = sender.split("!")[0] + + if len(parts) >= 3: + left_channel = parts[2] + print(f"*** {sender} left {left_channel}") + + # Quit messages + elif command == "QUIT": + sender = parts[0] + if sender.startswith(":"): + sender = sender[1:] + if "!" in sender: + sender = sender.split("!")[0] + + quit_message = "" + if len(parts) >= 3: + quit_message = parts[2] + if quit_message.startswith(":"): + quit_message = quit_message[1:] + + print(f"*** {sender} quit ({quit_message})") + + return lines_added From b036cc6252a21da095e043a43a3f30031ece78f7 Mon Sep 17 00:00:00 2001 From: foamyguy Date: Tue, 29 Jul 2025 10:01:06 -0500 Subject: [PATCH 2/4] icon and metadata for FJOS --- Fruit_Jam/Fruit_Jam_IRC_Client/icon.bmp | Bin 0 -> 2206 bytes Fruit_Jam/Fruit_Jam_IRC_Client/metadata.json | 4 ++++ 2 files changed, 4 insertions(+) create mode 100644 Fruit_Jam/Fruit_Jam_IRC_Client/icon.bmp create mode 100644 Fruit_Jam/Fruit_Jam_IRC_Client/metadata.json diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/icon.bmp b/Fruit_Jam/Fruit_Jam_IRC_Client/icon.bmp new file mode 100644 index 0000000000000000000000000000000000000000..879f8e836b2779deea68571055dea30d1e0fa50d GIT binary patch literal 2206 zcmZ?royWld2J?Vq4G=p(F(U&DkOc%ld1XBaW(9IVn1SIx41sx0?m@+11_=Zc7PVJ2 zK!T4L7@mA#`2YVugUdhz2atPQT%Z)hH6S4f07?i73JL%zB_$;Vm|K~Rp}=71$`kS* z3)Hi?{68uW382yN!Ja=x(+36V!+^mU9)6&VieCOOFfha9A+|uuCvctyN-zWEVfh>XA>^5jAx5B Date: Tue, 29 Jul 2025 10:21:40 -0500 Subject: [PATCH 3/4] pylint and format --- Fruit_Jam/Fruit_Jam_IRC_Client/code.py | 4 +-- .../Fruit_Jam_IRC_Client/curses_irc_client.py | 11 +++---- Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py | 29 ++++++++++--------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/code.py b/Fruit_Jam/Fruit_Jam_IRC_Client/code.py index ab43dcfb4..1ea8ea9f4 100644 --- a/Fruit_Jam/Fruit_Jam_IRC_Client/code.py +++ b/Fruit_Jam/Fruit_Jam_IRC_Client/code.py @@ -1,9 +1,9 @@ # SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries # SPDX-License-Identifier: MIT +from os import getenv from displayio import Group from terminalio import FONT import supervisor -from os import getenv import audiocore import board import busio @@ -57,7 +57,7 @@ print("could not connect to AP, retrying: ", e) continue -print(f"IRC Configuration:") +print("IRC Configuration:") print(f"Server: {IRC_CONFIG['server']}:{IRC_CONFIG['port']}") print(f"Nickname: {IRC_CONFIG['username']}") print(f"Channel: {IRC_CONFIG['channel']}") diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py b/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py index 09397b55d..6301a7fbc 100644 --- a/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py +++ b/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries # SPDX-License-Identifier: MIT -import adafruit_dang as curses import time +import adafruit_dang as curses from irc_client import IRCClient @@ -48,6 +48,7 @@ def irc_client_main( audio_interface=None, beep_wave=None, ): + # pylint: disable=too-many-locals, too-many-branches, too-many-statements """ Main curses IRC client application loop. """ @@ -98,9 +99,9 @@ def get_page(row_index): page_end = page_start + page_len page = irc_client.message_buffer[page_start:page_end] - # print(f"get_page({row_index}) len: {len(page)} start: {page_start} end: {page_end} rows: {window.n_rows - 2}") return page + # pylint: disable=too-many-nested-blocks try: # main application loop while True: @@ -128,7 +129,7 @@ def get_page(row_index): user_message_row = terminal_tilegrid.height - 1 if status_bar["user_message"] is None: - message = f" {irc_config['username']} | {irc_config['server']} | {irc_config['channel']}" + message = f" {irc_config['username']} | {irc_config['server']} | {irc_config['channel']}" # pylint: disable=line-too-long message += " " * (terminal_tilegrid.width - len(message) - 1) line = f"{ANSI_BLACK_ON_GREY}{message}{ANSI_RESET}" else: @@ -226,9 +227,9 @@ def get_page(row_index): else: print(f"unknown key: {k}") - except KeyboardInterrupt: + except KeyboardInterrupt as exc: irc_client.disconnect() - raise KeyboardInterrupt + raise KeyboardInterrupt from exc def run_irc_client( diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py b/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py index 8e7e6c232..1bb8b5ea8 100644 --- a/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py +++ b/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: MIT import time - import adafruit_connection_manager ANSI_ESCAPE_CODES = [ @@ -22,7 +21,8 @@ class IRCClient: Handles interaction with IRC Server and makes incoming messages available. :param radio: The network radio to connect with. - :param dict irc_config: Dictionary containing IRC configration for server, port, username and channel. + :param dict irc_config: Dictionary containing IRC configration for + server, port, username and channel. :param audio_interface: Optional interface to play audio from for beep messages :param beep_wave: Optional wave file to use for beep messages :param int max_line_length: Maximum characters per line to format messages into. @@ -142,7 +142,7 @@ def update(self): # no data before timeout # print(e) if "ETIMEDOUT" not in str(e): - raise RuntimeError(e) + raise RuntimeError(e) from e # raise RuntimeError("Connection timed out") return updated_display_lines @@ -163,7 +163,6 @@ def send_dm(self, to_user, message): """ irc_command = f"PRIVMSG {to_user} :{message}\r\n" self.socket.send(irc_command.encode("utf-8")) - # self.process_message(f":{self.config['username']}!~{self.config['username']}@localhost " + irc_command[:-2]) color = self.get_color_for_user(to_user) self.message_buffer.append(f"DM out: <{color}{to_user}{ANSI_RESET}> {message}") @@ -205,7 +204,7 @@ def get_technical_name(self, nickname): if "ETIMEDOUT" in str(e): whois_resp_lines = None else: - raise RuntimeError(e) + raise RuntimeError(e) from e if whois_resp_lines is None: return None @@ -221,10 +220,11 @@ def get_technical_name(self, nickname): whois_response = parts[2].split(" ", 1)[1] response_parts = whois_response.split(" ") - showname = response_parts[0] technical_name = f"*!{response_parts[1]}@{response_parts[2]}" return technical_name + return None + def ban(self, user): """ Ban the specified user from the channel @@ -301,13 +301,14 @@ def split_string_chunks(s, chunk_size): return chunks def process_message(self, message): + # pylint: disable=too-many-branches, too-many-statements """ Process an incoming IRC message :param message: The message that came from the IRC server. :return lines_added: The number of lines added to the display """ - + # pylint: disable=too-many-locals lines_added = 0 message = message.lstrip("\x00") @@ -317,12 +318,12 @@ def process_message(self, message): if message.startswith("PING"): pong_response = message.replace("PING", "PONG") self.socket.send(f"{pong_response}\r\n".encode("utf-8")) - print(f"Responded to PING") + print("Responded to PING") return 0 # Parse IRC message format: :prefix COMMAND params parts = message.split(" ", 2) - + # pylint: disable=too-many-nested-blocks if len(parts) >= 2: command = parts[1] try: @@ -331,7 +332,7 @@ def process_message(self, message): command_num = None # End of MOTD - now we can join the channel - if command == "376" or command == "422": # 422 is "no MOTD" + if command in {"376", "422"}: # 422 is "no MOTD" # join channel self.join() @@ -357,7 +358,7 @@ def process_message(self, message): welcome_text = welcome_text[1:] print( - f"'{welcome_text[0:11]}' startswith '{self.config['username']}' ? {welcome_text.startswith(self.config['username'])}" + f"'{welcome_text[0:11]}' startswith '{self.config['username']}' ? {welcome_text.startswith(self.config['username'])}" # pylint: disable=line-too-long ) if welcome_text.startswith(self.config["username"]): welcome_text = welcome_text.replace( @@ -451,11 +452,13 @@ def process_message(self, message): action_user = parts[0].split("!", 1)[0][1:] mode_msg_parts = parts[2].split(" ", 2) if len(mode_msg_parts) >= 3: - channel, mode, target_user = mode_msg_parts + channel, mode, target_user = ( + mode_msg_parts # pylint: disable=unused-variable + ) action_user_color = self.get_color_for_user(action_user) target_user_color = self.get_color_for_user(target_user) self.message_buffer.append( - f"{action_user_color}{action_user}{ANSI_RESET} sets mode {mode} on {target_user_color}{target_user}{ANSI_RESET}" + f"{action_user_color}{action_user}{ANSI_RESET} sets mode {mode} on {target_user_color}{target_user}{ANSI_RESET}" # pylint: disable=line-too-long ) lines_added += 1 From f8c2b61fea8c775e00d93a688efeeed44eccd69a Mon Sep 17 00:00:00 2001 From: foamyguy Date: Tue, 29 Jul 2025 10:30:35 -0500 Subject: [PATCH 4/4] more pylint --- Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py | 2 +- Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py b/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py index 6301a7fbc..e00bdc43f 100644 --- a/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py +++ b/Fruit_Jam/Fruit_Jam_IRC_Client/curses_irc_client.py @@ -133,7 +133,7 @@ def get_page(row_index): message += " " * (terminal_tilegrid.width - len(message) - 1) line = f"{ANSI_BLACK_ON_GREY}{message}{ANSI_RESET}" else: - line = f"{ANSI_BLACK_ON_GREY}{status_bar["user_message"]}{ANSI_RESET}" + line = f"{ANSI_BLACK_ON_GREY}{status_bar['user_message']}{ANSI_RESET}" if status_bar["user_message_shown_time"] + 3.0 < time.monotonic(): status_bar["user_message"] = None setline(user_message_row, line) diff --git a/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py b/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py index 1bb8b5ea8..09831ce6e 100644 --- a/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py +++ b/Fruit_Jam/Fruit_Jam_IRC_Client/irc_client.py @@ -452,8 +452,8 @@ def process_message(self, message): action_user = parts[0].split("!", 1)[0][1:] mode_msg_parts = parts[2].split(" ", 2) if len(mode_msg_parts) >= 3: - channel, mode, target_user = ( - mode_msg_parts # pylint: disable=unused-variable + channel, mode, target_user = ( # pylint: disable=unused-variable + mode_msg_parts ) action_user_color = self.get_color_for_user(action_user) target_user_color = self.get_color_for_user(target_user)