forked from genewildish/Mainline
Merge pull request 'feat/ntfy-local' (#6) from feat/ntfy-local into main
Reviewed-on: genewildish/Mainline#6
This commit is contained in:
119
mainline.py
119
mainline.py
@@ -39,7 +39,7 @@ sys.path.insert(0, str(next((_VENV / "lib").glob("python*/site-packages"))))
|
|||||||
|
|
||||||
import feedparser # noqa: E402
|
import feedparser # noqa: E402
|
||||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||||
import random, time, re, signal, atexit, textwrap # noqa: E402
|
import random, time, re, signal, atexit, textwrap, threading # noqa: E402
|
||||||
try:
|
try:
|
||||||
import sounddevice as _sd
|
import sounddevice as _sd
|
||||||
import numpy as _np
|
import numpy as _np
|
||||||
@@ -58,6 +58,11 @@ MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
|||||||
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
|
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
|
||||||
FIREHOSE = '--firehose' in sys.argv
|
FIREHOSE = '--firehose' in sys.argv
|
||||||
|
|
||||||
|
# ntfy message queue
|
||||||
|
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1"
|
||||||
|
NTFY_POLL_INTERVAL = 15 # seconds between polls
|
||||||
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
# Poetry/literature sources — public domain via Project Gutenberg
|
# Poetry/literature sources — public domain via Project Gutenberg
|
||||||
POETRY_SOURCES = {
|
POETRY_SOURCES = {
|
||||||
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
|
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
|
||||||
@@ -516,6 +521,42 @@ def _start_mic():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── NTFY MESSAGE QUEUE ───────────────────────────────────
|
||||||
|
_ntfy_message = None # (title, body, monotonic_timestamp) or None
|
||||||
|
_ntfy_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _start_ntfy_poller():
|
||||||
|
"""Start background thread polling ntfy for messages."""
|
||||||
|
def _poll():
|
||||||
|
global _ntfy_message
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
NTFY_TOPIC, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
for line in resp.read().decode('utf-8', errors='replace').strip().split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if data.get("event") == "message":
|
||||||
|
with _ntfy_lock:
|
||||||
|
_ntfy_message = (
|
||||||
|
data.get("title", ""),
|
||||||
|
data.get("message", ""),
|
||||||
|
time.monotonic(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(NTFY_POLL_INTERVAL)
|
||||||
|
t = threading.Thread(target=_poll, daemon=True)
|
||||||
|
t.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _render_line(text, font=None):
|
def _render_line(text, font=None):
|
||||||
"""Render a line of text as terminal rows using OTF font + half-blocks."""
|
"""Render a line of text as terminal rows using OTF font + half-blocks."""
|
||||||
if font is None:
|
if font is None:
|
||||||
@@ -754,6 +795,7 @@ def _firehose_line(items, w):
|
|||||||
|
|
||||||
|
|
||||||
def stream(items):
|
def stream(items):
|
||||||
|
global _ntfy_message
|
||||||
random.shuffle(items)
|
random.shuffle(items)
|
||||||
pool = list(items)
|
pool = list(items)
|
||||||
seen = set()
|
seen = set()
|
||||||
@@ -781,12 +823,85 @@ def stream(items):
|
|||||||
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||||
return noise_cache[cy]
|
return noise_cache[cy]
|
||||||
|
|
||||||
|
# Message color: bright cyan/white — distinct from headline greens
|
||||||
|
MSG_COLOR = "\033[1;38;5;87m" # sky cyan
|
||||||
|
MSG_META = "\033[38;5;245m" # cool grey
|
||||||
|
MSG_BORDER = "\033[2;38;5;37m" # dim teal
|
||||||
|
_msg_cache = (None, None) # (cache_key, rendered_rows)
|
||||||
|
|
||||||
while queued < HEADLINE_LIMIT or active:
|
while queued < HEADLINE_LIMIT or active:
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
w, h = tw(), th()
|
w, h = tw(), th()
|
||||||
fh = FIREHOSE_H if FIREHOSE else 0
|
fh = FIREHOSE_H if FIREHOSE else 0
|
||||||
sh = h - fh
|
sh = h - fh
|
||||||
|
|
||||||
|
# ── Check for ntfy message ────────────────────────
|
||||||
|
msg_active = False
|
||||||
|
with _ntfy_lock:
|
||||||
|
if _ntfy_message is not None:
|
||||||
|
m_title, m_body, m_ts = _ntfy_message
|
||||||
|
if time.monotonic() - m_ts < MESSAGE_DISPLAY_SECS:
|
||||||
|
msg_active = True
|
||||||
|
else:
|
||||||
|
_ntfy_message = None # expired
|
||||||
|
|
||||||
|
if msg_active:
|
||||||
|
# ── MESSAGE state: freeze scroll, render message ──
|
||||||
|
buf = []
|
||||||
|
# Render message text with OTF font (cached across frames)
|
||||||
|
display_text = m_body or m_title or "(empty)"
|
||||||
|
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||||
|
cache_key = (display_text, w)
|
||||||
|
if _msg_cache[0] != cache_key:
|
||||||
|
msg_rows = _big_wrap(display_text, w - 4)
|
||||||
|
msg_rows = _lr_gradient(msg_rows)
|
||||||
|
_msg_cache = (cache_key, msg_rows)
|
||||||
|
else:
|
||||||
|
msg_rows = _msg_cache[1]
|
||||||
|
# Center vertically in scroll zone
|
||||||
|
total_h = len(msg_rows) + 4 # +4 for border + meta + padding
|
||||||
|
y_off = max(0, (sh - total_h) // 2)
|
||||||
|
for r in range(sh):
|
||||||
|
ri = r - y_off
|
||||||
|
if ri == 0 or ri == total_h - 1:
|
||||||
|
# Border lines
|
||||||
|
bar = "─" * (w - 4)
|
||||||
|
buf.append(f"\033[{r+1};1H {MSG_BORDER}{bar}{RST}\033[K")
|
||||||
|
elif 1 <= ri <= len(msg_rows):
|
||||||
|
ln = _vis_trunc(msg_rows[ri - 1], w)
|
||||||
|
buf.append(f"\033[{r+1};1H {ln}{RST}\033[K")
|
||||||
|
elif ri == len(msg_rows) + 1:
|
||||||
|
# Title line (if present and different from body)
|
||||||
|
if m_title and m_title != m_body:
|
||||||
|
meta = f" {MSG_META}\u2591 {m_title}{RST}"
|
||||||
|
else:
|
||||||
|
meta = ""
|
||||||
|
buf.append(f"\033[{r+1};1H{meta}\033[K")
|
||||||
|
elif ri == len(msg_rows) + 2:
|
||||||
|
# Source + timestamp
|
||||||
|
elapsed_s = int(time.monotonic() - m_ts)
|
||||||
|
remaining = max(0, MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||||
|
ts_str = datetime.now().strftime("%H:%M:%S")
|
||||||
|
meta = f" {MSG_META}\u2591 ntfy \u00b7 {ts_str} \u00b7 {remaining}s{RST}"
|
||||||
|
buf.append(f"\033[{r+1};1H{meta}\033[K")
|
||||||
|
else:
|
||||||
|
# Sparse noise outside the message
|
||||||
|
if random.random() < 0.06:
|
||||||
|
buf.append(f"\033[{r+1};1H{noise(w)}")
|
||||||
|
else:
|
||||||
|
buf.append(f"\033[{r+1};1H\033[K")
|
||||||
|
# Firehose keeps running during messages
|
||||||
|
if FIREHOSE and fh > 0:
|
||||||
|
for fr in range(fh):
|
||||||
|
fline = _firehose_line(items, w)
|
||||||
|
buf.append(f"\033[{sh + fr + 1};1H{fline}\033[K")
|
||||||
|
sys.stdout.buffer.write("".join(buf).encode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
elapsed = time.monotonic() - t0
|
||||||
|
time.sleep(max(0, _FRAME_DT - elapsed))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── SCROLL state: normal headline rendering ───────
|
||||||
# Advance scroll on schedule
|
# Advance scroll on schedule
|
||||||
scroll_accum += _FRAME_DT
|
scroll_accum += _FRAME_DT
|
||||||
while scroll_accum >= scroll_interval:
|
while scroll_accum >= scroll_interval:
|
||||||
@@ -937,6 +1052,8 @@ def main():
|
|||||||
mic_ok = _start_mic()
|
mic_ok = _start_mic()
|
||||||
if _HAS_MIC:
|
if _HAS_MIC:
|
||||||
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", mic_ok)
|
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", mic_ok)
|
||||||
|
ntfy_ok = _start_ntfy_poller()
|
||||||
|
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||||
if FIREHOSE:
|
if FIREHOSE:
|
||||||
boot_ln("Firehose", "ENGAGED", True)
|
boot_ln("Firehose", "ENGAGED", True)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user