opencode-rgbify-plugin 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/bridge/ble_bridge.py +32 -75
- package/dist/index.js +75 -50
- package/package.json +21 -1
- package/src/index.ts +76 -54
- package/bridge/__pycache__/ble_bridge.cpython-312.pyc +0 -0
- package/bridge/__pycache__/ble_bridge.cpython-313.pyc +0 -0
package/README.md
CHANGED
|
@@ -16,18 +16,20 @@ opencode events ──► src/index.ts (plugin) ──► raw tail ──► lin
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
- **`src/index.ts`** — the opencode plugin. Spawns the Python bridge and
|
|
19
|
-
forwards each delta's last
|
|
20
|
-
sanitization**: both auralizers treat any out-of-range byte as a
|
|
21
|
-
raw text is safe. (Sanitization existed for the scrolling-display era, where
|
|
19
|
+
forwards each delta's tail (last `TAIL_CHARS=4` chars) as a newline-delimited
|
|
20
|
+
line. **No sanitization**: both auralizers treat any out-of-range byte as a
|
|
21
|
+
rest, so raw text is safe. (Sanitization existed for the scrolling-display era, where
|
|
22
22
|
tags in the text would scroll across the matrix; the firmware now blits one
|
|
23
23
|
char at a time, so tags are harmless — and the old tag-swallowing state
|
|
24
24
|
machine could stick on an unbalanced `<` and silently discard whole streams.
|
|
25
25
|
That was the long-standing "auralizers go silent" bug.)
|
|
26
|
-
- **`bridge/ble_bridge.py`** — the BLE bridge. Reads lines from stdin
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
cadence (one ~33ms note per char,
|
|
30
|
-
firmware).
|
|
26
|
+
- **`bridge/ble_bridge.py`** — the BLE bridge. Reads lines from stdin and
|
|
27
|
+
writes each to the projector's `TEXT_BRIDGE` characteristic as a
|
|
28
|
+
write-without-response (fire-and-forget, no ACK wait), and auralizes each
|
|
29
|
+
char on the host at the firmware's native cadence (one ~33ms note per char,
|
|
30
|
+
log-scale frequency table identical to the firmware). The plugin feeds stdin
|
|
31
|
+
via a `ReadableStream` with latest-wins semantics, so opencode never blocks
|
|
32
|
+
on the bridge and no line ever queues up.
|
|
31
33
|
|
|
32
34
|
### Interrupt semantics
|
|
33
35
|
|
package/bridge/ble_bridge.py
CHANGED
|
@@ -83,9 +83,9 @@ SCAN_TIMEOUT = 5.0
|
|
|
83
83
|
# small instead of adding a fixed multi-second delay on every reconnect.
|
|
84
84
|
CONNECT_DELAY = 1.0
|
|
85
85
|
|
|
86
|
-
# Max time a single write
|
|
87
|
-
#
|
|
88
|
-
#
|
|
86
|
+
# Max time a single write may take. Writes are write-without-response
|
|
87
|
+
# (fire-and-forget), so they normally resolve in milliseconds; this is only a
|
|
88
|
+
# guard against a wedged BlueZ DBus call so a stalled link is torn down quickly.
|
|
89
89
|
WRITE_TIMEOUT = 3.0
|
|
90
90
|
|
|
91
91
|
# Fallback VOLUME poll interval (seconds). Notifications from the device are
|
|
@@ -97,16 +97,6 @@ VOL_POLL_SEC = 2.0
|
|
|
97
97
|
# freq = -1021 + c*37 Hz for non-space chars, whitespace = rest, volume 0-10).
|
|
98
98
|
SAMPLE_RATE = 44100
|
|
99
99
|
NOTE_SEC = 1.0 / 30
|
|
100
|
-
# Adaptive host pacing: the firmware's REAL per-char time is slower than its
|
|
101
|
-
# nominal 30fps under load (measured ACKs of ~43ms/char vs 33ms nominal), so a
|
|
102
|
-
# fixed 33ms host note runs out before the next ACK-gated dispatch — a small
|
|
103
|
-
# periodic silence, only present while plugged in. The BLE loop measures
|
|
104
|
-
# ack_ms/char after every write and EMA-smooths it into this variable;
|
|
105
|
-
# host_auralize synthesizes at that pace so host buffers last exactly as long
|
|
106
|
-
# as the ACK window. Clamped to sane bounds; falls back to nominal when cold.
|
|
107
|
-
NOTE_SEC_MIN = 0.020
|
|
108
|
-
NOTE_SEC_MAX = 0.080
|
|
109
|
-
ACK_PACE_EMA = 0.3
|
|
110
100
|
# Host note amplitude as a fraction of full-scale int16. The firmware drives a
|
|
111
101
|
# piezo at resonance (loud); the host speaker at 0.05 was nearly inaudible, at
|
|
112
102
|
# 0.4 it masked the piezo — 0.3 rebalances the mix.
|
|
@@ -493,12 +483,6 @@ async def main() -> None:
|
|
|
493
483
|
except (NotImplementedError, RuntimeError):
|
|
494
484
|
pass
|
|
495
485
|
|
|
496
|
-
lines: asyncio.Queue = asyncio.Queue()
|
|
497
|
-
# Adaptive host pacing (see NOTE_SEC_* above): the BLE loop measures the
|
|
498
|
-
# ACK time per char after every write and EMA-smooths it here;
|
|
499
|
-
# host_auralize synthesizes at this pace so host buffers last exactly as
|
|
500
|
-
# long as the ACK window — no periodic silence while plugged in.
|
|
501
|
-
ack_pace_sec = NOTE_SEC
|
|
502
486
|
# Latest-line slots (maxsize 1, replace-on-full): each sink keeps only the
|
|
503
487
|
# most recent line, so a newer line interrupts (replaces) the previous one.
|
|
504
488
|
host_line: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
@@ -510,7 +494,20 @@ async def main() -> None:
|
|
|
510
494
|
|
|
511
495
|
auralizer = HostAuralizer()
|
|
512
496
|
|
|
497
|
+
def push_latest(q: asyncio.Queue, line: str) -> None:
|
|
498
|
+
try:
|
|
499
|
+
q.put_nowait(line)
|
|
500
|
+
except asyncio.QueueFull:
|
|
501
|
+
q.get_nowait()
|
|
502
|
+
q.put_nowait(line)
|
|
503
|
+
|
|
513
504
|
async def read_stdin() -> None:
|
|
505
|
+
# Latest-line fan-out, no intermediary queue. While connected, a line
|
|
506
|
+
# goes to ble_line only — the BLE loop hands every written chunk back to
|
|
507
|
+
# host_line so the host plays EXACTLY the bytes the projector receives
|
|
508
|
+
# (char-perfect sync). While disconnected, lines go to host_line only,
|
|
509
|
+
# so the host keeps auralizing always-on and nothing accumulates for
|
|
510
|
+
# replay on reconnect.
|
|
514
511
|
while True:
|
|
515
512
|
raw = await reader.readline()
|
|
516
513
|
if not raw:
|
|
@@ -520,24 +517,6 @@ async def main() -> None:
|
|
|
520
517
|
return
|
|
521
518
|
line = raw.decode("utf-8", "replace").rstrip("\n")
|
|
522
519
|
dbg(f"in len={len(line)}")
|
|
523
|
-
await lines.put(line)
|
|
524
|
-
|
|
525
|
-
def push_latest(q: asyncio.Queue, line: str) -> None:
|
|
526
|
-
try:
|
|
527
|
-
q.put_nowait(line)
|
|
528
|
-
except asyncio.QueueFull:
|
|
529
|
-
q.get_nowait()
|
|
530
|
-
q.put_nowait(line)
|
|
531
|
-
|
|
532
|
-
async def broadcast() -> None:
|
|
533
|
-
# Fan every line out as the LATEST line. While the projector is
|
|
534
|
-
# connected, feed only ble_line — the BLE loop hands every chunk it
|
|
535
|
-
# writes back to host_line, so the host auralizer plays EXACTLY the
|
|
536
|
-
# bytes the projector receives (char-perfect sync). While disconnected,
|
|
537
|
-
# raw lines go to host_line only, so the host keeps auralizing
|
|
538
|
-
# always-on and nothing accumulates for replay on reconnect.
|
|
539
|
-
while not stop_event.is_set():
|
|
540
|
-
line = await lines.get()
|
|
541
520
|
if connected:
|
|
542
521
|
push_latest(ble_line, line)
|
|
543
522
|
else:
|
|
@@ -562,7 +541,7 @@ async def main() -> None:
|
|
|
562
541
|
if not line:
|
|
563
542
|
continue
|
|
564
543
|
auralizer.play_note(
|
|
565
|
-
synth_message(line, load_volume()
|
|
544
|
+
synth_message(line, load_volume())
|
|
566
545
|
)
|
|
567
546
|
finally:
|
|
568
547
|
auralizer.stop()
|
|
@@ -581,12 +560,11 @@ async def main() -> None:
|
|
|
581
560
|
return
|
|
582
561
|
|
|
583
562
|
asyncio.create_task(read_stdin())
|
|
584
|
-
asyncio.create_task(broadcast())
|
|
585
563
|
asyncio.create_task(watch_parent())
|
|
586
564
|
host_task = asyncio.create_task(host_auralize())
|
|
587
565
|
|
|
588
566
|
async def ble_loop() -> None:
|
|
589
|
-
nonlocal connected
|
|
567
|
+
nonlocal connected
|
|
590
568
|
while not stop_event.is_set():
|
|
591
569
|
try:
|
|
592
570
|
addr = await discover_address(override)
|
|
@@ -619,8 +597,8 @@ async def main() -> None:
|
|
|
619
597
|
# clients (bridge AND the RGBify website) share ONE BlueZ
|
|
620
598
|
# ACL link, so a device-wide disconnect ejected the
|
|
621
599
|
# website every time a bridge went away. Stale writes
|
|
622
|
-
# can't survive
|
|
623
|
-
#
|
|
600
|
+
# can't survive anyway — the firmware interrupts on every
|
|
601
|
+
# write, so there's nothing queued to replay.
|
|
624
602
|
|
|
625
603
|
async with BleakClient(addr, disconnected_callback=on_disconnect) as client:
|
|
626
604
|
# Clear anything that slipped in before the flag flipped, so
|
|
@@ -686,15 +664,12 @@ async def main() -> None:
|
|
|
686
664
|
apply_volume(value[0], "poll", False)
|
|
687
665
|
except Exception as e:
|
|
688
666
|
dbg(f"vol poll err {e}")
|
|
689
|
-
#
|
|
690
|
-
#
|
|
691
|
-
#
|
|
692
|
-
#
|
|
693
|
-
#
|
|
694
|
-
# either
|
|
695
|
-
# NOT discarded: they chain immediately after the ACK, so
|
|
696
|
-
# notes play back-to-back for as long as text keeps
|
|
697
|
-
# streaming.
|
|
667
|
+
# FIRE-AND-FORGET DELIVERY: each line (the last 8 chars of
|
|
668
|
+
# a delta) is written with response=False. The firmware's
|
|
669
|
+
# TEXT_BRIDGE onWrite resets its playback position on every
|
|
670
|
+
# write, so the newest message interrupts the previous one
|
|
671
|
+
# immediately — no ACK to wait for, no serialization, no
|
|
672
|
+
# backlog on either side.
|
|
698
673
|
while True:
|
|
699
674
|
kind, text = await wait_line_or_stop(
|
|
700
675
|
ble_line, stop_event, IDLE_CHECK_MS
|
|
@@ -711,51 +686,33 @@ async def main() -> None:
|
|
|
711
686
|
if not text:
|
|
712
687
|
continue
|
|
713
688
|
# SYNC: hand the message to the host auralizer NOW, so
|
|
714
|
-
# it starts
|
|
715
|
-
#
|
|
716
|
-
# put the host one message behind).
|
|
689
|
+
# it starts the same notes at the same moment the
|
|
690
|
+
# projector does.
|
|
717
691
|
push_latest(host_line, text)
|
|
718
692
|
dbg(f"host play len={len(text)}")
|
|
719
|
-
t_write = time.monotonic()
|
|
720
693
|
try:
|
|
721
694
|
await asyncio.wait_for(
|
|
722
695
|
client.write_gatt_char(
|
|
723
696
|
TEXT_BRIDGE_UUID, text.encode("utf-8"),
|
|
724
|
-
response=
|
|
697
|
+
response=False,
|
|
725
698
|
),
|
|
726
699
|
timeout=WRITE_TIMEOUT,
|
|
727
700
|
)
|
|
728
701
|
except asyncio.TimeoutError:
|
|
729
|
-
#
|
|
730
|
-
#
|
|
702
|
+
# BlueZ wedged: drop the link and reconnect rather
|
|
703
|
+
# than freeze both sinks.
|
|
731
704
|
dbg("write TIMEOUT")
|
|
732
|
-
raise ConnectionError("write
|
|
705
|
+
raise ConnectionError("write timed out")
|
|
733
706
|
except Exception as e:
|
|
734
707
|
# Transient/failed write: move on; the next delta
|
|
735
708
|
# starts fresh.
|
|
736
709
|
dbg(f"write err {e}")
|
|
737
710
|
continue
|
|
738
|
-
ack_ms = (time.monotonic() - t_write) * 1000.0
|
|
739
|
-
dbg(f"ack {ack_ms:.0f}ms")
|
|
740
|
-
# Adaptive host pacing: learn the firmware's REAL
|
|
741
|
-
# per-char playback time from this ACK so host buffers
|
|
742
|
-
# last exactly as long as the ACK window.
|
|
743
|
-
inst = min(NOTE_SEC_MAX, max(
|
|
744
|
-
NOTE_SEC_MIN, (ack_ms / 1000.0) / max(1, len(text))
|
|
745
|
-
))
|
|
746
|
-
ack_pace_sec += ACK_PACE_EMA * (inst - ack_pace_sec)
|
|
747
711
|
print("ok", flush=True)
|
|
748
712
|
# Fallback volume poll (rate-limited to VOL_POLL_SEC):
|
|
749
713
|
# catches webapp changes even if a notification is
|
|
750
714
|
# lost. Cheap monotonic-time check when under rate.
|
|
751
715
|
await poll_volume()
|
|
752
|
-
# NO flush here: anything that arrived during playback
|
|
753
|
-
# stays queued and plays immediately on the next loop
|
|
754
|
-
# iteration — notes chain back-to-back for as long as
|
|
755
|
-
# deltas keep coming ("keep playing until the next
|
|
756
|
-
# delta arrives"). The maxsize-1 queue bounds lag at
|
|
757
|
-
# ~one extra message; when deltas stop, the last one
|
|
758
|
-
# plays out and the auralizers go quiet.
|
|
759
716
|
except Exception as e:
|
|
760
717
|
connected = False
|
|
761
718
|
dbg(f"loop err {e}")
|
package/dist/index.js
CHANGED
|
@@ -76,6 +76,52 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
|
|
|
76
76
|
return {};
|
|
77
77
|
let procPromise = null;
|
|
78
78
|
const seenEventTypes = new Set();
|
|
79
|
+
// One-way, non-blocking delivery: a ReadableStream fed into the bridge's
|
|
80
|
+
// stdin. pull() waits for the newest line and enqueues it; a newer line
|
|
81
|
+
// replaces an un-consumed one (latest wins, no backlog). There is NO
|
|
82
|
+
// synchronous FileSink write/flush on opencode's event loop — that was the
|
|
83
|
+
// freeze (blocking the TUI whenever the pipe backed up under load).
|
|
84
|
+
let enqueueLine = null;
|
|
85
|
+
let pendingLine = null;
|
|
86
|
+
function makeStdinStream() {
|
|
87
|
+
let latest = null;
|
|
88
|
+
let wake = null;
|
|
89
|
+
let closed = false;
|
|
90
|
+
const enc = new TextEncoder();
|
|
91
|
+
const stream = new ReadableStream({
|
|
92
|
+
async pull(controller) {
|
|
93
|
+
while (latest === null && !closed) {
|
|
94
|
+
await new Promise((resolve) => {
|
|
95
|
+
wake = resolve;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (closed)
|
|
99
|
+
return;
|
|
100
|
+
const line = latest;
|
|
101
|
+
latest = null;
|
|
102
|
+
controller.enqueue(enc.encode(line + "\n"));
|
|
103
|
+
},
|
|
104
|
+
cancel() {
|
|
105
|
+
closed = true;
|
|
106
|
+
if (wake) {
|
|
107
|
+
const w = wake;
|
|
108
|
+
wake = null;
|
|
109
|
+
w();
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
enqueueLine = (line) => {
|
|
114
|
+
if (closed)
|
|
115
|
+
return;
|
|
116
|
+
latest = line;
|
|
117
|
+
if (wake) {
|
|
118
|
+
const w = wake;
|
|
119
|
+
wake = null;
|
|
120
|
+
w();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
return stream;
|
|
124
|
+
}
|
|
79
125
|
function startBridge() {
|
|
80
126
|
if (procPromise)
|
|
81
127
|
return procPromise;
|
|
@@ -84,7 +130,7 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
|
|
|
84
130
|
if (!python)
|
|
85
131
|
throw new Error("bridge deps not installed (install.sh failed)");
|
|
86
132
|
const proc = spawn([python, BRIDGE], {
|
|
87
|
-
stdin:
|
|
133
|
+
stdin: makeStdinStream(),
|
|
88
134
|
stdout: "pipe",
|
|
89
135
|
stderr: "pipe",
|
|
90
136
|
// Skip BLE discovery (a ~5s scan) on every connect: default to the
|
|
@@ -101,62 +147,44 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
|
|
|
101
147
|
// `.finally` so a dead bridge is replaced by the next send().
|
|
102
148
|
void proc.exited.finally(() => {
|
|
103
149
|
procPromise = null;
|
|
150
|
+
enqueueLine = null;
|
|
151
|
+
pendingLine = null;
|
|
104
152
|
});
|
|
153
|
+
// Flush any line that arrived before the bridge was up (latest only).
|
|
154
|
+
if (pendingLine !== null) {
|
|
155
|
+
enqueueLine?.(pendingLine);
|
|
156
|
+
pendingLine = null;
|
|
157
|
+
}
|
|
105
158
|
return proc;
|
|
106
159
|
})();
|
|
107
160
|
return procPromise;
|
|
108
161
|
}
|
|
109
|
-
// Delivery:
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
const TAIL_CHARS = 8;
|
|
117
|
-
const FLUSH_MS = 150;
|
|
118
|
-
let buf = "";
|
|
119
|
-
let flushTimer = null;
|
|
120
|
-
function flushBuf() {
|
|
121
|
-
if (flushTimer) {
|
|
122
|
-
clearTimeout(flushTimer);
|
|
123
|
-
flushTimer = null;
|
|
124
|
-
}
|
|
125
|
-
const line = buf.slice(-TAIL_CHARS);
|
|
126
|
-
buf = "";
|
|
127
|
-
if (line)
|
|
128
|
-
writeLine(line);
|
|
129
|
-
}
|
|
162
|
+
// Delivery: fire-and-forget. Each delta's tail goes straight to the bridge —
|
|
163
|
+
// no coalescing, no accumulation, no timer. The bridge keeps only the latest
|
|
164
|
+
// line, so the last delta is the only delta (no backlog). Keep SMALL: the
|
|
165
|
+
// bridge synthesizes host audio synchronously on its event loop at ~1ms/char,
|
|
166
|
+
// so a large tail stalls the loop and makes audio choppy. 4 = ~4ms, cleanest.
|
|
167
|
+
// Firmware cap is MAX_TEXT=256.
|
|
168
|
+
const TAIL_CHARS = 4;
|
|
130
169
|
function writeLine(line) {
|
|
131
170
|
debug(`send len=${line.length}`);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const r = stdin.flush();
|
|
140
|
-
if (r && typeof r.then === "function") {
|
|
141
|
-
;
|
|
142
|
-
r.catch(() => { });
|
|
143
|
-
}
|
|
144
|
-
})
|
|
145
|
-
.catch(async (err) => {
|
|
171
|
+
if (enqueueLine) {
|
|
172
|
+
enqueueLine(line);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
pendingLine = line;
|
|
176
|
+
}
|
|
177
|
+
startBridge().catch(async (err) => {
|
|
146
178
|
await client.app.log({
|
|
147
179
|
body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
|
|
148
180
|
});
|
|
149
181
|
});
|
|
150
182
|
}
|
|
151
183
|
function send(text) {
|
|
152
|
-
// Raw text,
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
if (!flushTimer)
|
|
159
|
-
flushTimer = setTimeout(flushBuf, FLUSH_MS);
|
|
184
|
+
// Raw text, tail-capped — no sanitization (see the note above).
|
|
185
|
+
const line = text.slice(-TAIL_CHARS);
|
|
186
|
+
if (line)
|
|
187
|
+
writeLine(line);
|
|
160
188
|
}
|
|
161
189
|
startBridge().catch(async (err) => {
|
|
162
190
|
await client.app.log({
|
|
@@ -212,12 +240,9 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
|
|
|
212
240
|
// orphan holding the projector connection. Closing its stdin would also do
|
|
213
241
|
// it (the bridge exits on EOF), but kill is immediate and explicit.
|
|
214
242
|
dispose: async () => {
|
|
215
|
-
// Drop any pending
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
flushTimer = null;
|
|
219
|
-
}
|
|
220
|
-
buf = "";
|
|
243
|
+
// Drop any pending line — the session is over.
|
|
244
|
+
enqueueLine = null;
|
|
245
|
+
pendingLine = null;
|
|
221
246
|
if (procPromise) {
|
|
222
247
|
try {
|
|
223
248
|
const proc = await procPromise;
|
package/package.json
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-rgbify-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "opencode plugin: stream chat text deltas to an RGBify 8x8 projector over BLE",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"opencode",
|
|
7
|
+
"plugin",
|
|
8
|
+
"rgbify",
|
|
9
|
+
"ble",
|
|
10
|
+
"bluetooth",
|
|
11
|
+
"led",
|
|
12
|
+
"matrix",
|
|
13
|
+
"esp32",
|
|
14
|
+
"auralizer",
|
|
15
|
+
"sonification"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/dcerisano/opencode-rgbify-plugin",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/dcerisano/opencode-rgbify-plugin/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/dcerisano/opencode-rgbify-plugin.git"
|
|
24
|
+
},
|
|
5
25
|
"type": "module",
|
|
6
26
|
"license": "MIT",
|
|
7
27
|
"main": "./dist/index.js",
|
package/src/index.ts
CHANGED
|
@@ -80,13 +80,59 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
|
|
|
80
80
|
let procPromise: Promise<ReturnType<typeof spawn>> | null = null
|
|
81
81
|
const seenEventTypes = new Set<string>()
|
|
82
82
|
|
|
83
|
+
// One-way, non-blocking delivery: a ReadableStream fed into the bridge's
|
|
84
|
+
// stdin. pull() waits for the newest line and enqueues it; a newer line
|
|
85
|
+
// replaces an un-consumed one (latest wins, no backlog). There is NO
|
|
86
|
+
// synchronous FileSink write/flush on opencode's event loop — that was the
|
|
87
|
+
// freeze (blocking the TUI whenever the pipe backed up under load).
|
|
88
|
+
let enqueueLine: ((line: string) => void) | null = null
|
|
89
|
+
let pendingLine: string | null = null
|
|
90
|
+
|
|
91
|
+
function makeStdinStream(): ReadableStream<Uint8Array> {
|
|
92
|
+
let latest: string | null = null
|
|
93
|
+
let wake: (() => void) | null = null
|
|
94
|
+
let closed = false
|
|
95
|
+
const enc = new TextEncoder()
|
|
96
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
97
|
+
async pull(controller) {
|
|
98
|
+
while (latest === null && !closed) {
|
|
99
|
+
await new Promise<void>((resolve) => {
|
|
100
|
+
wake = resolve
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
if (closed) return
|
|
104
|
+
const line = latest
|
|
105
|
+
latest = null
|
|
106
|
+
controller.enqueue(enc.encode(line + "\n"))
|
|
107
|
+
},
|
|
108
|
+
cancel() {
|
|
109
|
+
closed = true
|
|
110
|
+
if (wake) {
|
|
111
|
+
const w = wake
|
|
112
|
+
wake = null
|
|
113
|
+
w()
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
enqueueLine = (line: string) => {
|
|
118
|
+
if (closed) return
|
|
119
|
+
latest = line
|
|
120
|
+
if (wake) {
|
|
121
|
+
const w = wake
|
|
122
|
+
wake = null
|
|
123
|
+
w()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return stream
|
|
127
|
+
}
|
|
128
|
+
|
|
83
129
|
function startBridge(): Promise<ReturnType<typeof spawn>> {
|
|
84
130
|
if (procPromise) return procPromise
|
|
85
131
|
procPromise = (async () => {
|
|
86
132
|
const python = await bootstrapPython()
|
|
87
133
|
if (!python) throw new Error("bridge deps not installed (install.sh failed)")
|
|
88
134
|
const proc = spawn([python, BRIDGE], {
|
|
89
|
-
stdin:
|
|
135
|
+
stdin: makeStdinStream(),
|
|
90
136
|
stdout: "pipe",
|
|
91
137
|
stderr: "pipe",
|
|
92
138
|
// Skip BLE discovery (a ~5s scan) on every connect: default to the
|
|
@@ -106,66 +152,45 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
|
|
|
106
152
|
// `.finally` so a dead bridge is replaced by the next send().
|
|
107
153
|
void proc.exited.finally(() => {
|
|
108
154
|
procPromise = null
|
|
155
|
+
enqueueLine = null
|
|
156
|
+
pendingLine = null
|
|
109
157
|
})
|
|
158
|
+
// Flush any line that arrived before the bridge was up (latest only).
|
|
159
|
+
if (pendingLine !== null) {
|
|
160
|
+
enqueueLine?.(pendingLine)
|
|
161
|
+
pendingLine = null
|
|
162
|
+
}
|
|
110
163
|
return proc
|
|
111
164
|
})()
|
|
112
165
|
return procPromise
|
|
113
166
|
}
|
|
114
167
|
|
|
115
|
-
// Delivery:
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
const TAIL_CHARS = 8
|
|
123
|
-
const FLUSH_MS = 150
|
|
124
|
-
let buf = ""
|
|
125
|
-
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
126
|
-
|
|
127
|
-
function flushBuf() {
|
|
128
|
-
if (flushTimer) {
|
|
129
|
-
clearTimeout(flushTimer)
|
|
130
|
-
flushTimer = null
|
|
131
|
-
}
|
|
132
|
-
const line = buf.slice(-TAIL_CHARS)
|
|
133
|
-
buf = ""
|
|
134
|
-
if (line) writeLine(line)
|
|
135
|
-
}
|
|
168
|
+
// Delivery: fire-and-forget. Each delta's tail goes straight to the bridge —
|
|
169
|
+
// no coalescing, no accumulation, no timer. The bridge keeps only the latest
|
|
170
|
+
// line, so the last delta is the only delta (no backlog). Keep SMALL: the
|
|
171
|
+
// bridge synthesizes host audio synchronously on its event loop at ~1ms/char,
|
|
172
|
+
// so a large tail stalls the loop and makes audio choppy. 4 = ~4ms, cleanest.
|
|
173
|
+
// Firmware cap is MAX_TEXT=256.
|
|
174
|
+
const TAIL_CHARS = 4
|
|
136
175
|
|
|
137
176
|
function writeLine(line: string) {
|
|
138
177
|
debug(`send len=${line.length}`)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// Bun's FileSink buffers writes; without flush() the bridge receives
|
|
148
|
-
// them in delayed bursts, desyncing the projector/host from opencode.
|
|
149
|
-
const r = stdin.flush()
|
|
150
|
-
if (r && typeof (r as Promise<number>).then === "function") {
|
|
151
|
-
;(r as Promise<number>).catch(() => {})
|
|
152
|
-
}
|
|
153
|
-
})
|
|
154
|
-
.catch(async (err) => {
|
|
155
|
-
await client.app.log({
|
|
156
|
-
body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
|
|
157
|
-
})
|
|
178
|
+
if (enqueueLine) {
|
|
179
|
+
enqueueLine(line)
|
|
180
|
+
} else {
|
|
181
|
+
pendingLine = line
|
|
182
|
+
}
|
|
183
|
+
startBridge().catch(async (err) => {
|
|
184
|
+
await client.app.log({
|
|
185
|
+
body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
|
|
158
186
|
})
|
|
187
|
+
})
|
|
159
188
|
}
|
|
160
189
|
|
|
161
190
|
function send(text: string) {
|
|
162
|
-
// Raw text,
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
flushBuf()
|
|
166
|
-
return
|
|
167
|
-
}
|
|
168
|
-
if (!flushTimer) flushTimer = setTimeout(flushBuf, FLUSH_MS)
|
|
191
|
+
// Raw text, tail-capped — no sanitization (see the note above).
|
|
192
|
+
const line = text.slice(-TAIL_CHARS)
|
|
193
|
+
if (line) writeLine(line)
|
|
169
194
|
}
|
|
170
195
|
|
|
171
196
|
startBridge().catch(async (err) => {
|
|
@@ -223,12 +248,9 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
|
|
|
223
248
|
// orphan holding the projector connection. Closing its stdin would also do
|
|
224
249
|
// it (the bridge exits on EOF), but kill is immediate and explicit.
|
|
225
250
|
dispose: async () => {
|
|
226
|
-
// Drop any pending
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
flushTimer = null
|
|
230
|
-
}
|
|
231
|
-
buf = ""
|
|
251
|
+
// Drop any pending line — the session is over.
|
|
252
|
+
enqueueLine = null
|
|
253
|
+
pendingLine = null
|
|
232
254
|
if (procPromise) {
|
|
233
255
|
try {
|
|
234
256
|
const proc = await procPromise
|
|
Binary file
|
|
Binary file
|