Skip to content

Commit e44ae35

Browse files
authored
Merge pull request #3088 from FoamyGuy/fruitjam_irc
Fruit Jam IRC app
2 parents 895120f + f8c2b61 commit e44ae35

File tree

6 files changed

+821
-0
lines changed

6 files changed

+821
-0
lines changed
4.73 KB
Binary file not shown.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
2+
# SPDX-License-Identifier: MIT
3+
from os import getenv
4+
from displayio import Group
5+
from terminalio import FONT
6+
import supervisor
7+
import audiocore
8+
import board
9+
import busio
10+
from digitalio import DigitalInOut
11+
from adafruit_esp32spi import adafruit_esp32spi
12+
from adafruit_color_terminal import ColorTerminal
13+
from adafruit_fruitjam.peripherals import Peripherals
14+
15+
from curses_irc_client import run_irc_client
16+
17+
# Configuration - modify these values as needed
18+
IRC_CONFIG = {
19+
"server": "irc.libera.chat", # Example: irc.libera.chat, irc.freenode.net
20+
# "port": 6667, # 6667 - clear text
21+
"port": 6697, # 6697 - TLS encrypted
22+
"username": "",
23+
"channel": "#adafruit-fruit-jam",
24+
}
25+
26+
if IRC_CONFIG["username"] == "":
27+
raise ValueError("username must be set in IRC_CONFIG")
28+
29+
main_group = Group()
30+
display = supervisor.runtime.display
31+
32+
font_bb = FONT.get_bounding_box()
33+
screen_size = (display.width // font_bb[0], display.height // font_bb[1])
34+
35+
terminal = ColorTerminal(FONT, screen_size[0], screen_size[1])
36+
main_group.append(terminal.tilegrid)
37+
display.root_group = main_group
38+
39+
# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml
40+
# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.)
41+
ssid = getenv("CIRCUITPY_WIFI_SSID")
42+
password = getenv("CIRCUITPY_WIFI_PASSWORD")
43+
44+
# If you are using a board with pre-defined ESP32 Pins:
45+
esp32_cs = DigitalInOut(board.ESP_CS)
46+
esp32_ready = DigitalInOut(board.ESP_BUSY)
47+
esp32_reset = DigitalInOut(board.ESP_RESET)
48+
49+
spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
50+
esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
51+
52+
print("Connecting to AP...")
53+
while not esp.is_connected:
54+
try:
55+
esp.connect_AP(ssid, password)
56+
except RuntimeError as e:
57+
print("could not connect to AP, retrying: ", e)
58+
continue
59+
60+
print("IRC Configuration:")
61+
print(f"Server: {IRC_CONFIG['server']}:{IRC_CONFIG['port']}")
62+
print(f"Nickname: {IRC_CONFIG['username']}")
63+
print(f"Channel: {IRC_CONFIG['channel']}")
64+
print("-" * 40)
65+
66+
fruit_jam_peripherals = Peripherals()
67+
beep_wave = audiocore.WaveFile("beep.wav")
68+
run_irc_client(
69+
esp,
70+
IRC_CONFIG,
71+
terminal,
72+
terminal.tilegrid,
73+
audio_interface=fruit_jam_peripherals.audio,
74+
beep_wave=beep_wave,
75+
)
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
2+
# SPDX-License-Identifier: MIT
3+
import time
4+
import adafruit_dang as curses
5+
6+
from irc_client import IRCClient
7+
8+
ANSI_BLACK_ON_GREY = chr(27) + "[30;100m"
9+
ANSI_RESET = chr(27) + "[0m"
10+
11+
12+
class Window:
13+
"""
14+
Terminal Window class that supports basic scrolling.
15+
"""
16+
17+
def __init__(self, n_rows, n_cols, row=0, col=0):
18+
self.n_rows = n_rows
19+
self.n_cols = n_cols
20+
self.row = row
21+
self.col = col
22+
23+
@property
24+
def bottom(self):
25+
return self.row + self.n_rows - 1
26+
27+
def up(self, cursor): # pylint: disable=invalid-name
28+
if cursor.row == self.row - 1 and self.row > 0:
29+
self.row -= 1
30+
31+
def down(self, buffer, cursor):
32+
if cursor.row == self.bottom + 1 and self.bottom < len(buffer) - 1:
33+
self.row += 1
34+
35+
def horizontal_scroll(self, cursor, left_margin=5, right_margin=2):
36+
n_pages = cursor.col // (self.n_cols - right_margin)
37+
self.col = max(n_pages * self.n_cols - right_margin - left_margin, 0)
38+
39+
def translate(self, cursor):
40+
return cursor.row - self.row, cursor.col - self.col
41+
42+
43+
def irc_client_main(
44+
stdscr,
45+
radio,
46+
irc_config,
47+
terminal_tilegrid=None,
48+
audio_interface=None,
49+
beep_wave=None,
50+
):
51+
# pylint: disable=too-many-locals, too-many-branches, too-many-statements
52+
"""
53+
Main curses IRC client application loop.
54+
"""
55+
irc_client = IRCClient(
56+
radio, irc_config, audio_interface=audio_interface, beep_wave=beep_wave
57+
)
58+
irc_client.connect()
59+
# irc_client.join()
60+
61+
window = Window(terminal_tilegrid.height, terminal_tilegrid.width)
62+
stdscr.erase()
63+
img = [None] * window.n_rows
64+
status_bar = {
65+
"user_message": None,
66+
"user_message_shown_time": 0,
67+
}
68+
69+
cur_row_index = 0
70+
71+
user_input = ""
72+
73+
def show_user_message(message):
74+
"""
75+
Show a status message to the user
76+
"""
77+
status_bar["user_message"] = message + (
78+
" " * (window.n_cols - 1 - len(message))
79+
)
80+
status_bar["user_message_shown_time"] = time.monotonic()
81+
82+
def setline(row, line):
83+
"""
84+
Set a line of text in the terminal window.
85+
"""
86+
if img[row] == line:
87+
return
88+
img[row] = line
89+
line += " " * (window.n_cols - len(line) - 1)
90+
stdscr.addstr(row, 0, line)
91+
92+
def get_page(row_index):
93+
"""
94+
Get a page of messages from the message buffer.
95+
"""
96+
page_len = window.n_rows - 2
97+
98+
page_start = max((len(irc_client.message_buffer) + row_index) - page_len, 0)
99+
page_end = page_start + page_len
100+
101+
page = irc_client.message_buffer[page_start:page_end]
102+
return page
103+
104+
# pylint: disable=too-many-nested-blocks
105+
try:
106+
# main application loop
107+
while True:
108+
lastrow = 0
109+
lines_added = irc_client.update()
110+
111+
cur_page = get_page(cur_row_index)
112+
113+
if lines_added > 0 and len(cur_page) < window.n_rows - 2:
114+
cur_row_index = max(cur_row_index - lines_added, 0)
115+
cur_page = get_page(cur_row_index)
116+
117+
for row, line in enumerate(cur_page):
118+
lastrow = row
119+
setline(row, line)
120+
121+
for row in range(lastrow + 1, window.n_rows - 2):
122+
setline(row, "")
123+
124+
user_input_row = window.n_rows - 2
125+
if user_input:
126+
setline(user_input_row, user_input)
127+
else:
128+
setline(user_input_row, " " * (window.n_cols - 1))
129+
130+
user_message_row = terminal_tilegrid.height - 1
131+
if status_bar["user_message"] is None:
132+
message = f" {irc_config['username']} | {irc_config['server']} | {irc_config['channel']}" # pylint: disable=line-too-long
133+
message += " " * (terminal_tilegrid.width - len(message) - 1)
134+
line = f"{ANSI_BLACK_ON_GREY}{message}{ANSI_RESET}"
135+
else:
136+
line = f"{ANSI_BLACK_ON_GREY}{status_bar['user_message']}{ANSI_RESET}"
137+
if status_bar["user_message_shown_time"] + 3.0 < time.monotonic():
138+
status_bar["user_message"] = None
139+
setline(user_message_row, line)
140+
141+
# read from the keyboard
142+
k = stdscr.getkey()
143+
if k is not None:
144+
if len(k) == 1 and " " <= k <= "~":
145+
user_input += k
146+
147+
elif k == "\n": # enter key pressed
148+
if not user_input.startswith("/"):
149+
print(f"sending: {user_input}")
150+
irc_client.send_message(user_input)
151+
user_input = ""
152+
else: # slash commands
153+
parts = user_input.split(" ", 1)
154+
if parts[0] in {"/j", "/join"}:
155+
if len(parts) >= 2 and parts[1] != "":
156+
if parts[1] != irc_client.config["channel"]:
157+
irc_client.join(parts[1])
158+
user_input = ""
159+
else:
160+
show_user_message("Already in channel")
161+
user_input = ""
162+
163+
else:
164+
show_user_message(
165+
"Invalid /join arg. Use: /join <channel>"
166+
)
167+
user_input = ""
168+
elif parts[0] == "/msg":
169+
to_user, message_to_send = parts[1].split(" ", 1)
170+
irc_client.send_dm(to_user, message_to_send)
171+
user_input = ""
172+
elif parts[0] == "/beep":
173+
to_user = parts[1]
174+
message_to_send = "*Beep*\x07"
175+
irc_client.send_dm(to_user, message_to_send)
176+
user_input = ""
177+
elif parts[0] == "/op":
178+
user_to_op = parts[1]
179+
irc_client.op(user_to_op)
180+
user_input = ""
181+
elif parts[0] == "/deop":
182+
user_to_op = parts[1]
183+
irc_client.deop(user_to_op)
184+
user_input = ""
185+
elif parts[0] == "/kick":
186+
user_to_kick = parts[1]
187+
irc_client.kick(user_to_kick)
188+
user_input = ""
189+
elif parts[0] == "/ban":
190+
user_to_ban = parts[1]
191+
irc_client.ban(user_to_ban)
192+
user_input = ""
193+
elif parts[0] == "/unban":
194+
user_to_unban = parts[1]
195+
irc_client.unban(user_to_unban)
196+
user_input = ""
197+
elif parts[0] == "/whois":
198+
user_to_check = parts[1]
199+
irc_client.whois(user_to_check)
200+
user_input = ""
201+
202+
elif k in ("KEY_BACKSPACE", "\x7f", "\x08"):
203+
user_input = user_input[:-1]
204+
elif k == "KEY_UP":
205+
page_len = window.n_rows - 2
206+
if len(irc_client.message_buffer) > page_len:
207+
page_start = (
208+
len(irc_client.message_buffer) + cur_row_index
209+
) - page_len
210+
if page_start > 0:
211+
cur_row_index -= 1
212+
elif k == "KEY_DOWN":
213+
if cur_row_index < 0:
214+
cur_row_index += 1
215+
216+
elif k == "KEY_PGUP":
217+
page_len = window.n_rows - 2
218+
if len(irc_client.message_buffer) > page_len:
219+
page_start = (
220+
len(irc_client.message_buffer) + cur_row_index
221+
) - page_len
222+
if page_start > 0:
223+
cur_row_index -= 6
224+
elif k == "KEY_PGDN":
225+
if cur_row_index <= 0:
226+
cur_row_index = cur_row_index + 6
227+
else:
228+
print(f"unknown key: {k}")
229+
230+
except KeyboardInterrupt as exc:
231+
irc_client.disconnect()
232+
raise KeyboardInterrupt from exc
233+
234+
235+
def run_irc_client(
236+
radio, irc_config, terminal, terminal_tilegrid, audio_interface=None, beep_wave=None
237+
):
238+
"""
239+
Entry point to run the curses IRC client application.
240+
"""
241+
return curses.custom_terminal_wrapper(
242+
terminal,
243+
irc_client_main,
244+
radio,
245+
irc_config,
246+
terminal_tilegrid,
247+
audio_interface,
248+
beep_wave,
249+
)
2.15 KB
Binary file not shown.

0 commit comments

Comments
 (0)