Skip to content

Commit d22c263

Browse files
committed
fruit jam IRC app
1 parent 895120f commit d22c263

File tree

4 files changed

+813
-0
lines changed

4 files changed

+813
-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 displayio import Group
4+
from terminalio import FONT
5+
import supervisor
6+
from os import getenv
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(f"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: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
2+
# SPDX-License-Identifier: MIT
3+
import adafruit_dang as curses
4+
import time
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+
"""
52+
Main curses IRC client application loop.
53+
"""
54+
irc_client = IRCClient(
55+
radio, irc_config, audio_interface=audio_interface, beep_wave=beep_wave
56+
)
57+
irc_client.connect()
58+
# irc_client.join()
59+
60+
window = Window(terminal_tilegrid.height, terminal_tilegrid.width)
61+
stdscr.erase()
62+
img = [None] * window.n_rows
63+
status_bar = {
64+
"user_message": None,
65+
"user_message_shown_time": 0,
66+
}
67+
68+
cur_row_index = 0
69+
70+
user_input = ""
71+
72+
def show_user_message(message):
73+
"""
74+
Show a status message to the user
75+
"""
76+
status_bar["user_message"] = message + (
77+
" " * (window.n_cols - 1 - len(message))
78+
)
79+
status_bar["user_message_shown_time"] = time.monotonic()
80+
81+
def setline(row, line):
82+
"""
83+
Set a line of text in the terminal window.
84+
"""
85+
if img[row] == line:
86+
return
87+
img[row] = line
88+
line += " " * (window.n_cols - len(line) - 1)
89+
stdscr.addstr(row, 0, line)
90+
91+
def get_page(row_index):
92+
"""
93+
Get a page of messages from the message buffer.
94+
"""
95+
page_len = window.n_rows - 2
96+
97+
page_start = max((len(irc_client.message_buffer) + row_index) - page_len, 0)
98+
page_end = page_start + page_len
99+
100+
page = irc_client.message_buffer[page_start:page_end]
101+
# print(f"get_page({row_index}) len: {len(page)} start: {page_start} end: {page_end} rows: {window.n_rows - 2}")
102+
return page
103+
104+
try:
105+
# main application loop
106+
while True:
107+
lastrow = 0
108+
lines_added = irc_client.update()
109+
110+
cur_page = get_page(cur_row_index)
111+
112+
if lines_added > 0 and len(cur_page) < window.n_rows - 2:
113+
cur_row_index = max(cur_row_index - lines_added, 0)
114+
cur_page = get_page(cur_row_index)
115+
116+
for row, line in enumerate(cur_page):
117+
lastrow = row
118+
setline(row, line)
119+
120+
for row in range(lastrow + 1, window.n_rows - 2):
121+
setline(row, "")
122+
123+
user_input_row = window.n_rows - 2
124+
if user_input:
125+
setline(user_input_row, user_input)
126+
else:
127+
setline(user_input_row, " " * (window.n_cols - 1))
128+
129+
user_message_row = terminal_tilegrid.height - 1
130+
if status_bar["user_message"] is None:
131+
message = f" {irc_config['username']} | {irc_config['server']} | {irc_config['channel']}"
132+
message += " " * (terminal_tilegrid.width - len(message) - 1)
133+
line = f"{ANSI_BLACK_ON_GREY}{message}{ANSI_RESET}"
134+
else:
135+
line = f"{ANSI_BLACK_ON_GREY}{status_bar["user_message"]}{ANSI_RESET}"
136+
if status_bar["user_message_shown_time"] + 3.0 < time.monotonic():
137+
status_bar["user_message"] = None
138+
setline(user_message_row, line)
139+
140+
# read from the keyboard
141+
k = stdscr.getkey()
142+
if k is not None:
143+
if len(k) == 1 and " " <= k <= "~":
144+
user_input += k
145+
146+
elif k == "\n": # enter key pressed
147+
if not user_input.startswith("/"):
148+
print(f"sending: {user_input}")
149+
irc_client.send_message(user_input)
150+
user_input = ""
151+
else: # slash commands
152+
parts = user_input.split(" ", 1)
153+
if parts[0] in {"/j", "/join"}:
154+
if len(parts) >= 2 and parts[1] != "":
155+
if parts[1] != irc_client.config["channel"]:
156+
irc_client.join(parts[1])
157+
user_input = ""
158+
else:
159+
show_user_message("Already in channel")
160+
user_input = ""
161+
162+
else:
163+
show_user_message(
164+
"Invalid /join arg. Use: /join <channel>"
165+
)
166+
user_input = ""
167+
elif parts[0] == "/msg":
168+
to_user, message_to_send = parts[1].split(" ", 1)
169+
irc_client.send_dm(to_user, message_to_send)
170+
user_input = ""
171+
elif parts[0] == "/beep":
172+
to_user = parts[1]
173+
message_to_send = "*Beep*\x07"
174+
irc_client.send_dm(to_user, message_to_send)
175+
user_input = ""
176+
elif parts[0] == "/op":
177+
user_to_op = parts[1]
178+
irc_client.op(user_to_op)
179+
user_input = ""
180+
elif parts[0] == "/deop":
181+
user_to_op = parts[1]
182+
irc_client.deop(user_to_op)
183+
user_input = ""
184+
elif parts[0] == "/kick":
185+
user_to_kick = parts[1]
186+
irc_client.kick(user_to_kick)
187+
user_input = ""
188+
elif parts[0] == "/ban":
189+
user_to_ban = parts[1]
190+
irc_client.ban(user_to_ban)
191+
user_input = ""
192+
elif parts[0] == "/unban":
193+
user_to_unban = parts[1]
194+
irc_client.unban(user_to_unban)
195+
user_input = ""
196+
elif parts[0] == "/whois":
197+
user_to_check = parts[1]
198+
irc_client.whois(user_to_check)
199+
user_input = ""
200+
201+
elif k in ("KEY_BACKSPACE", "\x7f", "\x08"):
202+
user_input = user_input[:-1]
203+
elif k == "KEY_UP":
204+
page_len = window.n_rows - 2
205+
if len(irc_client.message_buffer) > page_len:
206+
page_start = (
207+
len(irc_client.message_buffer) + cur_row_index
208+
) - page_len
209+
if page_start > 0:
210+
cur_row_index -= 1
211+
elif k == "KEY_DOWN":
212+
if cur_row_index < 0:
213+
cur_row_index += 1
214+
215+
elif k == "KEY_PGUP":
216+
page_len = window.n_rows - 2
217+
if len(irc_client.message_buffer) > page_len:
218+
page_start = (
219+
len(irc_client.message_buffer) + cur_row_index
220+
) - page_len
221+
if page_start > 0:
222+
cur_row_index -= 6
223+
elif k == "KEY_PGDN":
224+
if cur_row_index <= 0:
225+
cur_row_index = cur_row_index + 6
226+
else:
227+
print(f"unknown key: {k}")
228+
229+
except KeyboardInterrupt:
230+
irc_client.disconnect()
231+
raise KeyboardInterrupt
232+
233+
234+
def run_irc_client(
235+
radio, irc_config, terminal, terminal_tilegrid, audio_interface=None, beep_wave=None
236+
):
237+
"""
238+
Entry point to run the curses IRC client application.
239+
"""
240+
return curses.custom_terminal_wrapper(
241+
terminal,
242+
irc_client_main,
243+
radio,
244+
irc_config,
245+
terminal_tilegrid,
246+
audio_interface,
247+
beep_wave,
248+
)

0 commit comments

Comments
 (0)