muxplex-deck 0.4.0__py3-none-any.whl
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.
- deck_probe/__init__.py +14 -0
- deck_probe/__main__.py +6 -0
- deck_probe/capabilities.py +130 -0
- deck_probe/events.py +211 -0
- deck_probe/main.py +220 -0
- deck_probe/rendering.py +166 -0
- muxplex_deck/__init__.py +5 -0
- muxplex_deck/__main__.py +11 -0
- muxplex_deck/attention.py +81 -0
- muxplex_deck/cli.py +1122 -0
- muxplex_deck/config.py +267 -0
- muxplex_deck/device.py +101 -0
- muxplex_deck/device_real.py +157 -0
- muxplex_deck/emulator.py +530 -0
- muxplex_deck/focus.py +92 -0
- muxplex_deck/init_wizard.py +382 -0
- muxplex_deck/interaction.py +418 -0
- muxplex_deck/layout.py +211 -0
- muxplex_deck/main.py +1093 -0
- muxplex_deck/rendering.py +440 -0
- muxplex_deck/service.py +551 -0
- muxplex_deck/statusfile.py +176 -0
- muxplex_deck/views.py +92 -0
- muxplex_deck-0.4.0.dist-info/METADATA +639 -0
- muxplex_deck-0.4.0.dist-info/RECORD +27 -0
- muxplex_deck-0.4.0.dist-info/WHEEL +4 -0
- muxplex_deck-0.4.0.dist-info/entry_points.txt +4 -0
muxplex_deck/main.py
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
"""muxplex sidecar: entry point and hotplug + server-connectivity state machine.
|
|
2
|
+
|
|
3
|
+
Extends the proven `deck_probe/main.py` hotplug pattern (DEVICE_ABSENT <->
|
|
4
|
+
DEVICE_ACTIVE, backoff-on-error after unexpected active-session failures)
|
|
5
|
+
with a second layer of state nested inside DEVICE_ACTIVE:
|
|
6
|
+
|
|
7
|
+
- ACTIVE: deck present, server reachable and authenticated. Poll
|
|
8
|
+
`GET /api/sessions` + `GET /api/state` + `GET /api/settings` every
|
|
9
|
+
`poll_interval` seconds, resolve the server's current view (`.views`
|
|
10
|
+
module -- a port of muxplex's `filter_visible`), optionally reorder it
|
|
11
|
+
attention-first (`.attention`), page it (`.interaction.Pager`, dial 1),
|
|
12
|
+
and render keys/strip from the result -- repainting only what actually
|
|
13
|
+
changed, per key.
|
|
14
|
+
- SERVER_UNREACHABLE: deck present, server down/unreachable. Show it on the
|
|
15
|
+
strip, retry with exponential backoff (2s doubling, capped at 30s).
|
|
16
|
+
- AUTH_FAILED: deck present, server reachable but rejected the federation
|
|
17
|
+
key (401/403). Distinct from unreachable: logged CRITICAL, shown on the
|
|
18
|
+
strip, retried slowly (every 30s) -- never spins, never proceeds in a
|
|
19
|
+
lesser state.
|
|
20
|
+
|
|
21
|
+
Unplug at any point drops straight back to DEVICE_ABSENT (server traffic
|
|
22
|
+
stops entirely). Ctrl+C/SIGTERM blanks the deck (if present) and exits 0.
|
|
23
|
+
|
|
24
|
+
Controls are capability-driven (see `.layout`): on a deck with dials and a
|
|
25
|
+
touch strip (Stream Deck+), dial 0 cycles the server's (global)
|
|
26
|
+
`active_view` -- turning locally echoes the candidate on the strip and
|
|
27
|
+
debounces the actual `PATCH /api/state` -- and dial 1 pages the current
|
|
28
|
+
view locally (no server writes); see `.interaction` for both state
|
|
29
|
+
machines. On a deck with neither (Original/MK2/XL/Mini), three reserved
|
|
30
|
+
keys play those roles instead: VIEW (top-left; shows view + server, tap
|
|
31
|
+
opens a paged view picker on the session-slot keys -- VIEW becomes BACK,
|
|
32
|
+
PREV/NEXT page the list, tapping a view PATCHes the server-global
|
|
33
|
+
`active_view`), PREV/NEXT (bottom corners; paging). All of this
|
|
34
|
+
plus the poll loop's own GETs share one `MuxplexClient`, so every actual
|
|
35
|
+
HTTP call (poll-loop GETs, a dial-0 commit's PATCH+refresh, a key press's
|
|
36
|
+
connect) is serialized through `_ActiveRuntime.client_lock` -- dial/key
|
|
37
|
+
callbacks arrive on a device-callback thread, distinct from the poll loop's
|
|
38
|
+
thread, and `httpx.Client` concurrency across threads is not a guarantee
|
|
39
|
+
worth relying on here.
|
|
40
|
+
|
|
41
|
+
This module depends only on the `DeckDevice` / `DeviceManager` protocols in
|
|
42
|
+
`.device` -- never on the `streamdeck` library or hidapi directly. Which
|
|
43
|
+
backend actually implements those protocols (real hardware via
|
|
44
|
+
`device_real.py`, or the in-process `emulator.py`) is chosen once in
|
|
45
|
+
`main()` based on `--emulator`, and everything below that point is
|
|
46
|
+
backend-agnostic.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import logging
|
|
52
|
+
import signal
|
|
53
|
+
import threading
|
|
54
|
+
import time
|
|
55
|
+
from urllib.parse import urlparse
|
|
56
|
+
|
|
57
|
+
from . import attention, focus, interaction, layout, rendering, views
|
|
58
|
+
from muxplex_client import (
|
|
59
|
+
AuthError,
|
|
60
|
+
MuxplexClient,
|
|
61
|
+
MuxplexError,
|
|
62
|
+
ServerState,
|
|
63
|
+
Session,
|
|
64
|
+
Settings,
|
|
65
|
+
UnreachableError,
|
|
66
|
+
)
|
|
67
|
+
from .config import Config
|
|
68
|
+
from .device import (
|
|
69
|
+
DeckDevice,
|
|
70
|
+
DeviceManager,
|
|
71
|
+
DialEventType,
|
|
72
|
+
TouchscreenEventType,
|
|
73
|
+
)
|
|
74
|
+
from .interaction import Pager, PickerController, PickerMode, ViewCycler
|
|
75
|
+
from .statusfile import StatusReporter
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger("muxplex_deck")
|
|
78
|
+
|
|
79
|
+
DEVICE_POLL_SECONDS = 2.0
|
|
80
|
+
ABSENT_HEARTBEAT_SECONDS = 30.0
|
|
81
|
+
HEALTH_CHECK_TICK_SECONDS = 1.0
|
|
82
|
+
|
|
83
|
+
INITIAL_BACKOFF_SECONDS = 2.0
|
|
84
|
+
MAX_BACKOFF_SECONDS = 30.0
|
|
85
|
+
AUTH_RETRY_SECONDS = 30.0
|
|
86
|
+
|
|
87
|
+
# Real hardware powers on at a dim firmware default; the sidecar always
|
|
88
|
+
# asserts full brightness itself on every bring-up (fresh connect or
|
|
89
|
+
# replug) rather than trusting whatever the deck inherited.
|
|
90
|
+
FULL_BRIGHTNESS_PERCENT = 100
|
|
91
|
+
|
|
92
|
+
_MAX_VIEW_LABEL_CHARS = 20
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _configure_logging() -> None:
|
|
96
|
+
logging.basicConfig(
|
|
97
|
+
level=logging.INFO,
|
|
98
|
+
format="%(asctime)s.%(msecs)03d %(levelname)s %(message)s",
|
|
99
|
+
datefmt="%H:%M:%S",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _find_deck(manager: DeviceManager) -> DeckDevice | None:
|
|
104
|
+
return manager.find_device()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _log_device_info(deck: DeckDevice) -> None:
|
|
108
|
+
logger.info("connected: %s", deck.deck_type())
|
|
109
|
+
logger.info(" serial number: %s", deck.get_serial_number())
|
|
110
|
+
logger.info(" firmware version: %s", deck.get_firmware_version())
|
|
111
|
+
logger.info(" key count: %d", deck.key_count())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _safe_close(deck: DeckDevice) -> None:
|
|
115
|
+
"""Reset and close the device, swallowing (but logging) any I/O errors.
|
|
116
|
+
|
|
117
|
+
Backend-specific "this is an expected disconnect error, not a bug"
|
|
118
|
+
exceptions (e.g. the real backend's `TransportError`) are swallowed
|
|
119
|
+
*inside* the backend's `reset()`/`close()` -- this function only needs
|
|
120
|
+
to guard against genuinely unexpected failures, from either backend.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
if deck.is_open():
|
|
124
|
+
deck.reset()
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.exception("Unexpected error while resetting deck during close")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
deck.close()
|
|
130
|
+
except Exception:
|
|
131
|
+
logger.exception("Unexpected error while closing deck")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _interruptible_wait(
|
|
135
|
+
deck: DeckDevice, shutting_down: threading.Event, seconds: float
|
|
136
|
+
) -> bool:
|
|
137
|
+
"""Wait up to `seconds`, checking shutdown and device-health every ~1s.
|
|
138
|
+
|
|
139
|
+
Returns True if the caller should abandon this active session (shutdown
|
|
140
|
+
requested, or the device went away) rather than waiting out the full
|
|
141
|
+
duration -- keeps unplug/Ctrl+C responsive even during a 30s backoff.
|
|
142
|
+
"""
|
|
143
|
+
remaining = seconds
|
|
144
|
+
while remaining > 0:
|
|
145
|
+
step = min(HEALTH_CHECK_TICK_SECONDS, remaining)
|
|
146
|
+
if shutting_down.wait(step):
|
|
147
|
+
return True
|
|
148
|
+
if not deck.is_open() or not deck.connected():
|
|
149
|
+
return True
|
|
150
|
+
remaining -= step
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _truncate_view(name: str) -> str:
|
|
155
|
+
if len(name) <= _MAX_VIEW_LABEL_CHARS:
|
|
156
|
+
return name
|
|
157
|
+
return name[: _MAX_VIEW_LABEL_CHARS - 1] + "\u2026"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_strip_message(
|
|
161
|
+
*,
|
|
162
|
+
view_label: str,
|
|
163
|
+
turning: bool,
|
|
164
|
+
page: int,
|
|
165
|
+
page_count: int,
|
|
166
|
+
hostname: str,
|
|
167
|
+
total: int,
|
|
168
|
+
active_session: str | None,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Compose the touch-strip headline: view (+ live turn echo) + page + host + status."""
|
|
171
|
+
view_part = _truncate_view(view_label)
|
|
172
|
+
if turning:
|
|
173
|
+
# ASCII, not "\u2192" (RIGHTWARDS ARROW): the real device's default
|
|
174
|
+
# PIL font has no glyph for it and renders a .notdef box instead
|
|
175
|
+
# (real-hardware feedback). The middot separator below renders fine.
|
|
176
|
+
view_part = f"> {view_part}"
|
|
177
|
+
parts = [view_part]
|
|
178
|
+
if page_count > 1:
|
|
179
|
+
parts.append(f"p{page}/{page_count}")
|
|
180
|
+
parts.append(hostname)
|
|
181
|
+
parts.append(f"{total} sessions")
|
|
182
|
+
parts.append(f"ACTIVE: {active_session or 'none'}")
|
|
183
|
+
return " \u00b7 ".join(parts)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _build_picker_strip_message(
|
|
187
|
+
*, kind: str, start: int, total: int, page_size: int
|
|
188
|
+
) -> str:
|
|
189
|
+
"""Compose the touch-strip headline while a picker (view/page) is open.
|
|
190
|
+
|
|
191
|
+
`kind` is "VIEW" or "PAGE". A "start-stop/total" range is only shown
|
|
192
|
+
once there are more options than fit in one window (matching the
|
|
193
|
+
session strip's own "only show page info when there's more than one
|
|
194
|
+
page" convention).
|
|
195
|
+
"""
|
|
196
|
+
parts = [f"{kind} PICKER -- tap to choose"]
|
|
197
|
+
if total > page_size:
|
|
198
|
+
first = start + 1
|
|
199
|
+
last = min(start + page_size, total)
|
|
200
|
+
parts.append(f"{first}-{last}/{total}")
|
|
201
|
+
return " \u00b7 ".join(parts)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _paint_status_only(deck: DeckDevice, message: str, plan: layout.LayoutPlan) -> None:
|
|
205
|
+
"""Blank the keys and show `message` wherever this deck can show status.
|
|
206
|
+
|
|
207
|
+
Decks with a touch strip get the message there (pre-existing behavior);
|
|
208
|
+
strip-less decks get it word-wrapped onto the VIEW key's position (or
|
|
209
|
+
key 0 on a degenerate grid) -- the log always carries the full detail.
|
|
210
|
+
"""
|
|
211
|
+
with deck:
|
|
212
|
+
rendering.paint_blank_keys(deck)
|
|
213
|
+
if plan.use_strip:
|
|
214
|
+
rendering.paint_status_strip(deck, message)
|
|
215
|
+
else:
|
|
216
|
+
status_key = plan.view_key if plan.view_key is not None else 0
|
|
217
|
+
deck.set_key_image(status_key, rendering.render_status_key(deck, message))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _ActiveRuntime:
|
|
221
|
+
"""All per-connection mutable state for one active (device+server) session.
|
|
222
|
+
|
|
223
|
+
Single home for: the last fetched+ordered session list, dial-driven
|
|
224
|
+
view-cycle/paging state, per-key/strip change-detection caches, and the
|
|
225
|
+
lock serializing every client HTTP call. Constructed fresh in
|
|
226
|
+
`_run_active` for each connection, so a reconnect always starts from a
|
|
227
|
+
clean slate (fresh debounce state, fresh "have we logged the
|
|
228
|
+
no-last_activity_at notice yet" flag, etc.).
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
deck: DeckDevice,
|
|
234
|
+
client: MuxplexClient,
|
|
235
|
+
hostname: str,
|
|
236
|
+
sort_mode: str,
|
|
237
|
+
focus_app_name: str = "",
|
|
238
|
+
) -> None:
|
|
239
|
+
self.deck = deck
|
|
240
|
+
self.client = client
|
|
241
|
+
self.hostname = hostname
|
|
242
|
+
self.sort_mode = sort_mode
|
|
243
|
+
# macOS app name of the local muxplex PWA to bring forward on a
|
|
244
|
+
# key-press session switch; "" disables (see `.focus`).
|
|
245
|
+
self.focus_app_name = focus_app_name
|
|
246
|
+
|
|
247
|
+
# Guards every actual HTTP call -- poll-loop GETs, a dial-0 commit's
|
|
248
|
+
# PATCH+refresh, and key-press connects can each originate from a
|
|
249
|
+
# different thread.
|
|
250
|
+
self.client_lock = threading.Lock()
|
|
251
|
+
# Guards the live session/view/page state and the paint-diff caches.
|
|
252
|
+
self.paint_lock = threading.Lock()
|
|
253
|
+
|
|
254
|
+
# Capability-driven layout: FULL (Stream Deck+: dials + strip) keeps
|
|
255
|
+
# the pre-existing behavior; REDUCED (Original/MK2/XL/Mini: no
|
|
256
|
+
# dials/strip) reserves VIEW/PREV/NEXT keys instead. See `.layout`.
|
|
257
|
+
self.plan = layout.plan_layout(layout.read_capabilities(deck))
|
|
258
|
+
|
|
259
|
+
self.view_cycler = ViewCycler()
|
|
260
|
+
self.pager = Pager(page_size=max(1, self.plan.sessions_per_page))
|
|
261
|
+
self.picker = PickerController()
|
|
262
|
+
|
|
263
|
+
self.ordered: list[Session] = []
|
|
264
|
+
self.active_session: str | None = None
|
|
265
|
+
self.active_view: str = "all"
|
|
266
|
+
self.session_names: list[str] = [] # current page's key-index -> session name
|
|
267
|
+
|
|
268
|
+
self.last_key_state: list[object] = [None] * deck.key_count()
|
|
269
|
+
self.last_strip: str | None = None
|
|
270
|
+
|
|
271
|
+
self.activity_logged = False
|
|
272
|
+
self.last_seen_active_view: str | None = None
|
|
273
|
+
|
|
274
|
+
def invalidate_paint_cache(self) -> None:
|
|
275
|
+
"""Force the next `repaint()` to redraw everything.
|
|
276
|
+
|
|
277
|
+
Called after a status-only screen (AUTH FAILED / UNREACHABLE) has
|
|
278
|
+
blanked the physical keys out from under our diff cache.
|
|
279
|
+
"""
|
|
280
|
+
with self.paint_lock:
|
|
281
|
+
self.last_key_state = [None] * self.deck.key_count()
|
|
282
|
+
self.last_strip = None
|
|
283
|
+
|
|
284
|
+
# --- fetch + process ---------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def refresh(self) -> None:
|
|
287
|
+
"""One GET-sessions/state/settings + process + repaint cycle.
|
|
288
|
+
|
|
289
|
+
Raises `AuthError` / `UnreachableError` / `MuxplexError` on failure;
|
|
290
|
+
callers (the poll loop, and a dial-0 commit) handle those.
|
|
291
|
+
"""
|
|
292
|
+
with self.client_lock:
|
|
293
|
+
sessions = self.client.sessions()
|
|
294
|
+
server_state = self.client.state()
|
|
295
|
+
settings = self.client.settings()
|
|
296
|
+
self._process(sessions, server_state, settings)
|
|
297
|
+
|
|
298
|
+
def _process(
|
|
299
|
+
self, sessions: list[Session], server_state: ServerState, settings: Settings
|
|
300
|
+
) -> None:
|
|
301
|
+
filtered = views.resolve_view(sessions, settings, server_state.active_view)
|
|
302
|
+
|
|
303
|
+
if self.sort_mode == "attention":
|
|
304
|
+
available = attention.activity_available(sessions)
|
|
305
|
+
if not available and not self.activity_logged:
|
|
306
|
+
logger.info(
|
|
307
|
+
"server does not expose last_activity_at; attention sort "
|
|
308
|
+
"using bell recency + base order"
|
|
309
|
+
)
|
|
310
|
+
self.activity_logged = True
|
|
311
|
+
ordered = attention.apply_attention_sort(
|
|
312
|
+
filtered, server_state.active_session, activity_available=available
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
ordered = filtered
|
|
316
|
+
|
|
317
|
+
view_names = [v.name for v in settings.views]
|
|
318
|
+
self.view_cycler.sync(view_names, server_state.active_view)
|
|
319
|
+
|
|
320
|
+
with self.paint_lock:
|
|
321
|
+
if (
|
|
322
|
+
self.last_seen_active_view is not None
|
|
323
|
+
and self.last_seen_active_view != server_state.active_view
|
|
324
|
+
):
|
|
325
|
+
self.pager.reset()
|
|
326
|
+
self.last_seen_active_view = server_state.active_view
|
|
327
|
+
|
|
328
|
+
self.ordered = ordered
|
|
329
|
+
self.active_session = server_state.active_session
|
|
330
|
+
self.active_view = server_state.active_view
|
|
331
|
+
self.pager.set_item_count(len(ordered))
|
|
332
|
+
|
|
333
|
+
self.repaint()
|
|
334
|
+
|
|
335
|
+
# --- painting ------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def repaint(self) -> None:
|
|
338
|
+
"""Repaint the keys (session page, or an open picker's options) and the strip.
|
|
339
|
+
|
|
340
|
+
Safe to call frequently and from any thread: a call that changes
|
|
341
|
+
nothing costs a slice + a few tuple comparisons, no device I/O.
|
|
342
|
+
"""
|
|
343
|
+
if self.picker.mode == PickerMode.NONE:
|
|
344
|
+
self._repaint_sessions()
|
|
345
|
+
else:
|
|
346
|
+
self._repaint_picker(self.picker.mode)
|
|
347
|
+
|
|
348
|
+
def _repaint_sessions(self) -> None:
|
|
349
|
+
with self.paint_lock:
|
|
350
|
+
start, stop = self.pager.slice_bounds()
|
|
351
|
+
slots = self.plan.session_slots
|
|
352
|
+
page_sessions = self.ordered[start:stop][: len(slots)]
|
|
353
|
+
self.session_names = [s.name for s in page_sessions]
|
|
354
|
+
active_session = self.active_session
|
|
355
|
+
turning = self.view_cycler.is_turning()
|
|
356
|
+
view_label = (
|
|
357
|
+
self.view_cycler.candidate_view() if turning else self.active_view
|
|
358
|
+
)
|
|
359
|
+
with self.deck:
|
|
360
|
+
self._paint_keys(page_sessions, active_session)
|
|
361
|
+
if self.plan.mode == layout.MODE_REDUCED:
|
|
362
|
+
self._paint_control_keys(view_label, turning)
|
|
363
|
+
if self.plan.use_strip:
|
|
364
|
+
message = _build_strip_message(
|
|
365
|
+
view_label=view_label,
|
|
366
|
+
turning=turning,
|
|
367
|
+
page=self.pager.page,
|
|
368
|
+
page_count=self.pager.page_count,
|
|
369
|
+
hostname=self.hostname,
|
|
370
|
+
total=len(self.ordered),
|
|
371
|
+
active_session=active_session,
|
|
372
|
+
)
|
|
373
|
+
if message != self.last_strip:
|
|
374
|
+
rendering.paint_status_strip(self.deck, message)
|
|
375
|
+
self.last_strip = message
|
|
376
|
+
|
|
377
|
+
def _repaint_picker(self, mode: PickerMode) -> None:
|
|
378
|
+
"""Repaint the keys as a picker (view names, or page numbers).
|
|
379
|
+
|
|
380
|
+
`mode` is passed in (rather than re-read) so a mode change between
|
|
381
|
+
`repaint()`'s dispatch and here can't leave this method confused
|
|
382
|
+
about which picker it's rendering.
|
|
383
|
+
|
|
384
|
+
FULL mode spreads the options across every key (the dial scrolls);
|
|
385
|
+
REDUCED mode shows them on the session-slot keys only, with the
|
|
386
|
+
reserved keys repurposed as BACK / PREV / NEXT (the picker's own
|
|
387
|
+
paging) -- see `_paint_reduced_picker`.
|
|
388
|
+
"""
|
|
389
|
+
with self.paint_lock:
|
|
390
|
+
reduced = (
|
|
391
|
+
self.plan.mode == layout.MODE_REDUCED and self.plan.view_key is not None
|
|
392
|
+
)
|
|
393
|
+
page_size = (
|
|
394
|
+
max(1, self.plan.sessions_per_page)
|
|
395
|
+
if reduced
|
|
396
|
+
else self.deck.key_count()
|
|
397
|
+
)
|
|
398
|
+
if mode == PickerMode.VIEW:
|
|
399
|
+
options = self.view_cycler.names()
|
|
400
|
+
current = self.active_view
|
|
401
|
+
kind = "VIEW"
|
|
402
|
+
else:
|
|
403
|
+
options = [str(n) for n in range(1, self.pager.page_count + 1)]
|
|
404
|
+
current = str(self.pager.page)
|
|
405
|
+
kind = "PAGE"
|
|
406
|
+
|
|
407
|
+
total = len(options)
|
|
408
|
+
# ticks=0 is a pure re-clamp: keeps the stored window valid if
|
|
409
|
+
# `total` shrank (e.g. a view was deleted) since it was last set.
|
|
410
|
+
start = self.picker.scroll(0, total=total, page_size=page_size)
|
|
411
|
+
window = options[start : start + page_size]
|
|
412
|
+
message = _build_picker_strip_message(
|
|
413
|
+
kind=kind, start=start, total=total, page_size=page_size
|
|
414
|
+
)
|
|
415
|
+
with self.deck:
|
|
416
|
+
if reduced:
|
|
417
|
+
self._paint_reduced_picker(
|
|
418
|
+
window, current, start=start, total=total, page_size=page_size
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
self._paint_picker_keys(window, current)
|
|
422
|
+
if self.plan.use_strip and message != self.last_strip:
|
|
423
|
+
rendering.paint_status_strip(self.deck, message)
|
|
424
|
+
self.last_strip = message
|
|
425
|
+
|
|
426
|
+
def _paint_picker_keys(self, options: list[str], current: str) -> None:
|
|
427
|
+
"""Paint the picker's key window -- one option label per key, diffed.
|
|
428
|
+
|
|
429
|
+
`current` marks whichever option matches today's actual active
|
|
430
|
+
view/page with the same cyan border used for the active session.
|
|
431
|
+
"""
|
|
432
|
+
key_count = self.deck.key_count()
|
|
433
|
+
for index in range(key_count):
|
|
434
|
+
label = options[index] if index < len(options) else None
|
|
435
|
+
is_current = label is not None and label == current
|
|
436
|
+
identity: object = None if label is None else ("picker", label, is_current)
|
|
437
|
+
if self.last_key_state[index] == identity:
|
|
438
|
+
continue
|
|
439
|
+
if label is None:
|
|
440
|
+
self.deck.set_key_image(index, rendering.render_empty_key(self.deck))
|
|
441
|
+
else:
|
|
442
|
+
self.deck.set_key_image(
|
|
443
|
+
index,
|
|
444
|
+
rendering.render_picker_key(self.deck, label, current=is_current),
|
|
445
|
+
)
|
|
446
|
+
self.last_key_state[index] = identity
|
|
447
|
+
|
|
448
|
+
def _paint_reduced_picker(
|
|
449
|
+
self,
|
|
450
|
+
window: list[str],
|
|
451
|
+
current: str,
|
|
452
|
+
*,
|
|
453
|
+
start: int,
|
|
454
|
+
total: int,
|
|
455
|
+
page_size: int,
|
|
456
|
+
) -> None:
|
|
457
|
+
"""Paint the reduced-layout picker: options on session slots, controls reserved.
|
|
458
|
+
|
|
459
|
+
The session-slot keys show the current window of options (one view
|
|
460
|
+
name per key, the active one marked with the same cyan border the
|
|
461
|
+
active session tile uses); the reserved keys are repurposed as
|
|
462
|
+
BACK (the VIEW key) and PREV/NEXT (the picker's own paging, with a
|
|
463
|
+
pN/M footer only when there is more than one page -- the same
|
|
464
|
+
convention the session grid's controls use). Same per-key diffing
|
|
465
|
+
as every other paint path.
|
|
466
|
+
"""
|
|
467
|
+
for slot, key_index in enumerate(self.plan.session_slots):
|
|
468
|
+
label = window[slot] if slot < len(window) else None
|
|
469
|
+
is_current = label is not None and label == current
|
|
470
|
+
identity: object = None if label is None else ("picker", label, is_current)
|
|
471
|
+
if self.last_key_state[key_index] == identity:
|
|
472
|
+
continue
|
|
473
|
+
if label is None:
|
|
474
|
+
self.deck.set_key_image(
|
|
475
|
+
key_index, rendering.render_empty_key(self.deck)
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
self.deck.set_key_image(
|
|
479
|
+
key_index,
|
|
480
|
+
rendering.render_picker_key(self.deck, label, current=is_current),
|
|
481
|
+
)
|
|
482
|
+
self.last_key_state[key_index] = identity
|
|
483
|
+
|
|
484
|
+
page = start // page_size + 1
|
|
485
|
+
page_count = max(1, (total + page_size - 1) // page_size)
|
|
486
|
+
page_text = f"p{page}/{page_count}" if page_count > 1 else ""
|
|
487
|
+
specs: list[tuple[int | None, str, str, str]] = [
|
|
488
|
+
(self.plan.view_key, "VIEW", "< BACK", ""),
|
|
489
|
+
(self.plan.prev_key, "", "< PREV", page_text),
|
|
490
|
+
(self.plan.next_key, "", "NEXT >", page_text),
|
|
491
|
+
]
|
|
492
|
+
for key_index, title, body, footer in specs:
|
|
493
|
+
if key_index is None:
|
|
494
|
+
continue
|
|
495
|
+
control_identity: object = ("control", title, body, footer)
|
|
496
|
+
if self.last_key_state[key_index] == control_identity:
|
|
497
|
+
continue
|
|
498
|
+
self.deck.set_key_image(
|
|
499
|
+
key_index,
|
|
500
|
+
rendering.render_control_key(
|
|
501
|
+
self.deck, title=title, body=body, footer=footer
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
self.last_key_state[key_index] = control_identity
|
|
505
|
+
|
|
506
|
+
def _paint_keys(
|
|
507
|
+
self, page_sessions: list[Session], active_session: str | None
|
|
508
|
+
) -> None:
|
|
509
|
+
"""Paint only the session-slot keys whose rendered content changed.
|
|
510
|
+
|
|
511
|
+
Iterates the plan's `session_slots` (every key in FULL mode; the
|
|
512
|
+
non-reserved keys in REDUCED mode), mapping slot position -> the
|
|
513
|
+
page's session at that position. `identity` captures every input
|
|
514
|
+
that affects a key's pixels (name, active flag, bell flag, and the
|
|
515
|
+
raw snapshot text driving the mini terminal preview) as a plain
|
|
516
|
+
tuple, compared by equality against the last-painted identity for
|
|
517
|
+
that slot. A literal hash isn't needed here -- tuple equality is
|
|
518
|
+
exactly as correct and simpler -- but it serves the same purpose:
|
|
519
|
+
skip a repaint (and a JPEG encode) for a key whose preview hasn't
|
|
520
|
+
scrolled since last poll.
|
|
521
|
+
"""
|
|
522
|
+
for slot, key_index in enumerate(self.plan.session_slots):
|
|
523
|
+
session = page_sessions[slot] if slot < len(page_sessions) else None
|
|
524
|
+
active = session is not None and session.name == active_session
|
|
525
|
+
identity: object = (
|
|
526
|
+
None
|
|
527
|
+
if session is None
|
|
528
|
+
else (
|
|
529
|
+
session.name,
|
|
530
|
+
active,
|
|
531
|
+
session.bell.needs_attention,
|
|
532
|
+
session.snapshot,
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
if self.last_key_state[key_index] == identity:
|
|
536
|
+
continue
|
|
537
|
+
if session is None:
|
|
538
|
+
self.deck.set_key_image(
|
|
539
|
+
key_index, rendering.render_empty_key(self.deck)
|
|
540
|
+
)
|
|
541
|
+
else:
|
|
542
|
+
self.deck.set_key_image(
|
|
543
|
+
key_index,
|
|
544
|
+
rendering.render_session_key(self.deck, session, active=active),
|
|
545
|
+
)
|
|
546
|
+
self.last_key_state[key_index] = identity
|
|
547
|
+
|
|
548
|
+
def _paint_control_keys(self, view_label: str, turning: bool) -> None:
|
|
549
|
+
"""Paint the reserved VIEW/PREV/NEXT keys (REDUCED mode only), diffed.
|
|
550
|
+
|
|
551
|
+
The VIEW key carries what the Deck+'s touch strip showed -- the
|
|
552
|
+
current view name (with the same "> " turn echo the strip used;
|
|
553
|
+
ASCII, because the default PIL font renders arrows as .notdef
|
|
554
|
+
boxes -- real-hardware feedback) plus the server label. PREV/NEXT
|
|
555
|
+
carry a pN/M footer whenever a view has more than one page,
|
|
556
|
+
matching the strip's own "only show page info when it matters"
|
|
557
|
+
convention.
|
|
558
|
+
"""
|
|
559
|
+
page = self.pager.page
|
|
560
|
+
page_count = self.pager.page_count
|
|
561
|
+
page_text = f"p{page}/{page_count}" if page_count > 1 else ""
|
|
562
|
+
view_body = f"> {view_label}" if turning else view_label
|
|
563
|
+
|
|
564
|
+
specs: list[tuple[int | None, str, str, str]] = [
|
|
565
|
+
(self.plan.view_key, "VIEW", view_body, self.hostname),
|
|
566
|
+
(self.plan.prev_key, "", "< PREV", page_text),
|
|
567
|
+
(self.plan.next_key, "", "NEXT >", page_text),
|
|
568
|
+
]
|
|
569
|
+
for key_index, title, body, footer in specs:
|
|
570
|
+
if key_index is None:
|
|
571
|
+
continue
|
|
572
|
+
identity: object = ("control", title, body, footer)
|
|
573
|
+
if self.last_key_state[key_index] == identity:
|
|
574
|
+
continue
|
|
575
|
+
self.deck.set_key_image(
|
|
576
|
+
key_index,
|
|
577
|
+
rendering.render_control_key(
|
|
578
|
+
self.deck, title=title, body=body, footer=footer
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
self.last_key_state[key_index] = identity
|
|
582
|
+
|
|
583
|
+
# --- dial handling ------------------------------------------------
|
|
584
|
+
|
|
585
|
+
def handle_view_dial(self, event_type: DialEventType, value: object) -> None:
|
|
586
|
+
if event_type == DialEventType.TURN:
|
|
587
|
+
ticks = int(value) # type: ignore[arg-type]
|
|
588
|
+
if self.picker.mode == PickerMode.VIEW:
|
|
589
|
+
total = len(self.view_cycler.names())
|
|
590
|
+
self.picker.scroll(ticks, total=total, page_size=self.deck.key_count())
|
|
591
|
+
elif self.picker.mode == PickerMode.NONE:
|
|
592
|
+
# Normal-mode turn behavior is unchanged -- only PRESS
|
|
593
|
+
# changes meaning (see `PickerController`'s docstring).
|
|
594
|
+
self.view_cycler.turn(int(value), self._commit_view) # type: ignore[arg-type]
|
|
595
|
+
# else: PAGE picker is open -- dial 0 isn't its owner, ignore the turn.
|
|
596
|
+
self.repaint()
|
|
597
|
+
elif event_type == DialEventType.PUSH and value:
|
|
598
|
+
logger.info("dial[0] pressed -> %s", self.picker.press_view_dial())
|
|
599
|
+
self.repaint()
|
|
600
|
+
|
|
601
|
+
def _commit_view(self, view: str) -> None:
|
|
602
|
+
"""Debounced (or press-immediate) dial-0 commit: PATCH, then refresh fast."""
|
|
603
|
+
logger.info("dial[0] view cycle commit -> %r", view)
|
|
604
|
+
try:
|
|
605
|
+
with self.client_lock:
|
|
606
|
+
self.client.set_active_view(view)
|
|
607
|
+
self.refresh()
|
|
608
|
+
except MuxplexError:
|
|
609
|
+
logger.exception("failed to commit view switch to %r", view)
|
|
610
|
+
message = f"view switch failed: {view}"
|
|
611
|
+
with self.paint_lock, self.deck:
|
|
612
|
+
rendering.paint_status_strip(self.deck, message)
|
|
613
|
+
self.last_strip = message
|
|
614
|
+
|
|
615
|
+
def handle_page_dial(self, event_type: DialEventType, value: object) -> None:
|
|
616
|
+
if event_type == DialEventType.TURN:
|
|
617
|
+
ticks = int(value) # type: ignore[arg-type]
|
|
618
|
+
if self.picker.mode == PickerMode.PAGE:
|
|
619
|
+
total = self.pager.page_count
|
|
620
|
+
self.picker.scroll(ticks, total=total, page_size=self.deck.key_count())
|
|
621
|
+
elif self.picker.mode == PickerMode.NONE:
|
|
622
|
+
# Normal-mode turn behavior is unchanged -- only PRESS
|
|
623
|
+
# changes meaning (see `PickerController`'s docstring).
|
|
624
|
+
self.pager.turn(int(value)) # type: ignore[arg-type]
|
|
625
|
+
# else: VIEW picker is open -- dial 1 isn't its owner, ignore the turn.
|
|
626
|
+
self.repaint()
|
|
627
|
+
elif event_type == DialEventType.PUSH and value:
|
|
628
|
+
logger.info("dial[1] pressed -> %s", self.picker.press_page_dial())
|
|
629
|
+
self.repaint()
|
|
630
|
+
|
|
631
|
+
# --- key handling ------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
def handle_key(self, key: int) -> None:
|
|
634
|
+
"""Dispatch a physical key press to its role under the layout plan.
|
|
635
|
+
|
|
636
|
+
In FULL mode every key is a session slot (`classify_key` maps key N
|
|
637
|
+
to slot N), so this is the pre-existing connect path unchanged. In
|
|
638
|
+
REDUCED mode the reserved VIEW/PREV/NEXT keys perform their control
|
|
639
|
+
action and never connect. Picker mode takes priority: in FULL mode
|
|
640
|
+
(pickers open via dial presses) a tap selects an option; in REDUCED
|
|
641
|
+
mode (the VIEW key opens the view picker) taps dispatch through the
|
|
642
|
+
pure `interaction.handle_picker_key` -- BACK cancels, PREV/NEXT
|
|
643
|
+
page, a view-slot tap selects.
|
|
644
|
+
"""
|
|
645
|
+
mode = self.picker.mode
|
|
646
|
+
if mode == PickerMode.VIEW:
|
|
647
|
+
if self.plan.mode == layout.MODE_REDUCED and self.plan.view_key is not None:
|
|
648
|
+
self._handle_reduced_picker_key(key)
|
|
649
|
+
else:
|
|
650
|
+
self._select_view_option(key)
|
|
651
|
+
return
|
|
652
|
+
if mode == PickerMode.PAGE:
|
|
653
|
+
self._select_page_option(key)
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
kind, slot = layout.classify_key(self.plan, key)
|
|
657
|
+
if kind == layout.KEY_VIEW:
|
|
658
|
+
# Open the paged view picker (replaces the old tap-to-cycle):
|
|
659
|
+
# the session-slot keys become view choices, VIEW becomes BACK,
|
|
660
|
+
# PREV/NEXT page the list. Selection PATCHes the server-global
|
|
661
|
+
# `active_view` -- same effect a dial-0 pick has on the Deck+.
|
|
662
|
+
self.picker.press_view_dial()
|
|
663
|
+
logger.info("key[%d] VIEW pressed -> view picker opened", key)
|
|
664
|
+
self.repaint()
|
|
665
|
+
return
|
|
666
|
+
if kind == layout.KEY_PREV:
|
|
667
|
+
page = self.pager.turn(-1)
|
|
668
|
+
logger.info("key[%d] PREV pressed -> page %d", key, page)
|
|
669
|
+
self.repaint()
|
|
670
|
+
return
|
|
671
|
+
if kind == layout.KEY_NEXT:
|
|
672
|
+
page = self.pager.turn(1)
|
|
673
|
+
logger.info("key[%d] NEXT pressed -> page %d", key, page)
|
|
674
|
+
self.repaint()
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
if slot is None:
|
|
678
|
+
logger.info("key[%d] pressed (unassigned, ignoring)", key)
|
|
679
|
+
return
|
|
680
|
+
self.connect_slot(slot, key)
|
|
681
|
+
|
|
682
|
+
def connect_slot(self, slot: int, key: int) -> None:
|
|
683
|
+
"""Connect the session shown in `slot` (pressed via physical `key`)."""
|
|
684
|
+
with self.paint_lock:
|
|
685
|
+
names = list(self.session_names)
|
|
686
|
+
if slot >= len(names):
|
|
687
|
+
logger.info("key[%d] pressed (empty slot, ignoring)", key)
|
|
688
|
+
return
|
|
689
|
+
name = names[slot]
|
|
690
|
+
logger.info(
|
|
691
|
+
"key[%d] pressed -> connect session %r (optimistic highlight)", key, name
|
|
692
|
+
)
|
|
693
|
+
# Move the highlight NOW (don't wait for the next poll tick) and run
|
|
694
|
+
# the actual HTTP connect on a background thread -- real-hardware
|
|
695
|
+
# feedback showed the old synchronous-on-the-callback-thread call
|
|
696
|
+
# (server-side ttyd kill+respawn, ~2.6s) blocked further device
|
|
697
|
+
# input and delayed the highlight by up to a full poll interval. The
|
|
698
|
+
# PWA already does this optimistically; this mirrors it.
|
|
699
|
+
with self.paint_lock:
|
|
700
|
+
self.active_session = name
|
|
701
|
+
self.repaint()
|
|
702
|
+
threading.Thread(target=self._do_connect, args=(name,), daemon=True).start()
|
|
703
|
+
|
|
704
|
+
def _do_connect(self, name: str) -> None:
|
|
705
|
+
"""Background-thread body for a key-press connect (see `connect`).
|
|
706
|
+
|
|
707
|
+
On failure, logs loudly and shows it on the strip -- but does not
|
|
708
|
+
try to revert the optimistic highlight itself: the next poll's
|
|
709
|
+
`refresh()` re-reads `/api/state` and repaints whatever the server
|
|
710
|
+
actually has, which self-heals a wrong guess without this method
|
|
711
|
+
needing to know anything about polling.
|
|
712
|
+
|
|
713
|
+
Focus fires on EVERY explicit key-press connect, whether or not the
|
|
714
|
+
press actually changes the active session: the physical button is
|
|
715
|
+
the user's request to bring the PWA to the foreground with that
|
|
716
|
+
session showing, and re-pressing the already-active session's key is
|
|
717
|
+
a legitimate way to reacquire the window (e.g. after alt-tabbing
|
|
718
|
+
away on the Mac) -- gating focus on a session CHANGE silently
|
|
719
|
+
dropped that use. Poll-driven repaints / dial actions never reach
|
|
720
|
+
this method at all, so focus still never fires on anything but an
|
|
721
|
+
explicit key press. Focus runs before the connect POST (it's ~100ms
|
|
722
|
+
vs the server's multi-second ttyd respawn, so the window is
|
|
723
|
+
foreground by the time the switch lands) and is best-effort:
|
|
724
|
+
`focus_app` swallows every failure. The connect POST itself is
|
|
725
|
+
unconditional too -- harmless when unchanged: the server already
|
|
726
|
+
short-circuits a same-session connect (no ttyd kill/respawn, ~2ms)
|
|
727
|
+
rather than this method needing to skip it.
|
|
728
|
+
"""
|
|
729
|
+
focus.focus_app(self.focus_app_name)
|
|
730
|
+
try:
|
|
731
|
+
with self.client_lock:
|
|
732
|
+
self.client.connect(name)
|
|
733
|
+
except MuxplexError:
|
|
734
|
+
logger.exception("failed to switch to session %r", name)
|
|
735
|
+
with self.paint_lock, self.deck:
|
|
736
|
+
message = f"switch failed: {name}"
|
|
737
|
+
rendering.paint_status_strip(self.deck, message)
|
|
738
|
+
self.last_strip = message
|
|
739
|
+
|
|
740
|
+
def _select_view_option(self, key: int) -> None:
|
|
741
|
+
names = self.view_cycler.names()
|
|
742
|
+
start = self.picker.window_start
|
|
743
|
+
index = start + key
|
|
744
|
+
self.picker.exit()
|
|
745
|
+
if index >= len(names):
|
|
746
|
+
logger.info("view picker: key[%d] pressed (empty slot, ignoring)", key)
|
|
747
|
+
self.repaint()
|
|
748
|
+
return
|
|
749
|
+
view = names[index]
|
|
750
|
+
logger.info("view picker: key[%d] selected -> view %r", key, view)
|
|
751
|
+
self._commit_view(view)
|
|
752
|
+
self.repaint()
|
|
753
|
+
|
|
754
|
+
def _handle_reduced_picker_key(self, key: int) -> None:
|
|
755
|
+
"""Dispatch a key press while the reduced-layout view picker is open.
|
|
756
|
+
|
|
757
|
+
The *decision* (cancel/select/page/ignore) is the pure
|
|
758
|
+
`interaction.handle_picker_key`; this method only applies its side
|
|
759
|
+
effects. A selection PATCHes the server-global `active_view` (via
|
|
760
|
+
`_commit_view` -> `MuxplexClient.set_active_view`), so every device
|
|
761
|
+
watching this server -- other decks, the PWA -- follows on its next
|
|
762
|
+
poll, exactly like a session connect.
|
|
763
|
+
"""
|
|
764
|
+
kind, slot = layout.classify_key(self.plan, key)
|
|
765
|
+
options = self.view_cycler.names()
|
|
766
|
+
result = interaction.handle_picker_key(
|
|
767
|
+
kind=kind,
|
|
768
|
+
slot=slot,
|
|
769
|
+
options=options,
|
|
770
|
+
window_start=self.picker.window_start,
|
|
771
|
+
page_size=max(1, self.plan.sessions_per_page),
|
|
772
|
+
)
|
|
773
|
+
if result.action == interaction.ACTION_CANCEL:
|
|
774
|
+
self.picker.exit()
|
|
775
|
+
logger.info("view picker: key[%d] BACK -> cancelled", key)
|
|
776
|
+
elif result.action == interaction.ACTION_SELECT and result.view is not None:
|
|
777
|
+
self.picker.exit()
|
|
778
|
+
logger.info("view picker: key[%d] selected -> view %r", key, result.view)
|
|
779
|
+
self._commit_view(result.view)
|
|
780
|
+
elif result.action == interaction.ACTION_PAGE:
|
|
781
|
+
self.picker.set_window(result.window_start)
|
|
782
|
+
logger.info(
|
|
783
|
+
"view picker: key[%d] page -> window start %d",
|
|
784
|
+
key,
|
|
785
|
+
result.window_start,
|
|
786
|
+
)
|
|
787
|
+
else:
|
|
788
|
+
logger.info("view picker: key[%d] pressed (empty slot, ignoring)", key)
|
|
789
|
+
self.repaint()
|
|
790
|
+
|
|
791
|
+
def _select_page_option(self, key: int) -> None:
|
|
792
|
+
total = self.pager.page_count
|
|
793
|
+
start = self.picker.window_start
|
|
794
|
+
index = start + key
|
|
795
|
+
self.picker.exit()
|
|
796
|
+
if index >= total:
|
|
797
|
+
logger.info("page picker: key[%d] pressed (empty slot, ignoring)", key)
|
|
798
|
+
self.repaint()
|
|
799
|
+
return
|
|
800
|
+
page = index + 1
|
|
801
|
+
logger.info("page picker: key[%d] selected -> page %d", key, page)
|
|
802
|
+
self.pager.go_to(page)
|
|
803
|
+
self.repaint()
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _make_key_callback(ctx: _ActiveRuntime):
|
|
807
|
+
def on_key(_deck: DeckDevice, key: int, pressed: bool) -> None:
|
|
808
|
+
if pressed:
|
|
809
|
+
ctx.handle_key(key)
|
|
810
|
+
|
|
811
|
+
return on_key
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _make_dial_callback(ctx: _ActiveRuntime):
|
|
815
|
+
def on_dial(
|
|
816
|
+
_deck: DeckDevice, dial: int, event_type: DialEventType, value: object
|
|
817
|
+
) -> None:
|
|
818
|
+
if dial == 0:
|
|
819
|
+
ctx.handle_view_dial(event_type, value)
|
|
820
|
+
elif dial == 1:
|
|
821
|
+
ctx.handle_page_dial(event_type, value)
|
|
822
|
+
else:
|
|
823
|
+
logger.info("dial[%d] %s %r (unassigned)", dial, event_type, value)
|
|
824
|
+
|
|
825
|
+
return on_dial
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _on_touch(_deck: DeckDevice, event_type: TouchscreenEventType, value: dict) -> None:
|
|
829
|
+
logger.info("touch %s %r (unassigned in v1)", event_type, value)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _describe_deck_caps(deck: DeckDevice) -> dict | None:
|
|
833
|
+
"""Best-effort capability dict for the status file (never fatal).
|
|
834
|
+
|
|
835
|
+
Same shape `deck_probe.capabilities.describe_capabilities` produces
|
|
836
|
+
(model, serial, firmware, key_count, key_rows/cols, dial_count,
|
|
837
|
+
has_touchscreen, is_visual, ...) -- both production `DeckDevice`
|
|
838
|
+
backends (`RealDeckDevice`, `EmulatorDevice`) satisfy the wider
|
|
839
|
+
`DeckCapabilitySource` protocol it needs (see
|
|
840
|
+
`test_device_protocol_contract.py`). A failure here must never take
|
|
841
|
+
down the active session -- it only degrades what `status` can show.
|
|
842
|
+
"""
|
|
843
|
+
try:
|
|
844
|
+
from deck_probe.capabilities import describe_capabilities
|
|
845
|
+
|
|
846
|
+
return describe_capabilities(deck) # type: ignore[arg-type]
|
|
847
|
+
except Exception:
|
|
848
|
+
logger.exception("failed to describe deck capabilities for status file")
|
|
849
|
+
return None
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _run_active(
|
|
853
|
+
deck: DeckDevice,
|
|
854
|
+
client: MuxplexClient,
|
|
855
|
+
shutting_down: threading.Event,
|
|
856
|
+
poll_interval: float,
|
|
857
|
+
hostname: str,
|
|
858
|
+
sort_mode: str,
|
|
859
|
+
focus_app_name: str,
|
|
860
|
+
reporter: StatusReporter,
|
|
861
|
+
) -> None:
|
|
862
|
+
"""Run one connected-device session against the muxplex server.
|
|
863
|
+
|
|
864
|
+
Returns when the device disconnects or shutdown is requested. Server
|
|
865
|
+
errors (unreachable/auth) are handled *inside* this loop -- they never
|
|
866
|
+
propagate up to trigger the outer hotplug recovery path, since they are
|
|
867
|
+
expected, recoverable conditions, not device errors.
|
|
868
|
+
"""
|
|
869
|
+
_log_device_info(deck)
|
|
870
|
+
try:
|
|
871
|
+
deck.set_brightness(FULL_BRIGHTNESS_PERCENT)
|
|
872
|
+
except Exception:
|
|
873
|
+
# Real hardware defaults to a dim power-on brightness; this always
|
|
874
|
+
# asserts full brightness on bring-up (real-hardware feedback) --
|
|
875
|
+
# but a device that just went away mid-open shouldn't be fatal here,
|
|
876
|
+
# the outer loop's is_open()/connected() checks handle that.
|
|
877
|
+
logger.exception("failed to set brightness to %d%%", FULL_BRIGHTNESS_PERCENT)
|
|
878
|
+
ctx = _ActiveRuntime(deck, client, hostname, sort_mode, focus_app_name)
|
|
879
|
+
logger.info("%s", layout.describe_plan(ctx.plan))
|
|
880
|
+
_paint_status_only(deck, "connecting to muxplex...", ctx.plan)
|
|
881
|
+
|
|
882
|
+
reporter.update(device_connected=True, device_caps=_describe_deck_caps(deck))
|
|
883
|
+
|
|
884
|
+
deck.set_key_callback(_make_key_callback(ctx))
|
|
885
|
+
if ctx.plan.use_dials:
|
|
886
|
+
deck.set_dial_callback(_make_dial_callback(ctx))
|
|
887
|
+
else:
|
|
888
|
+
logger.info("no dials assigned on this model -- view/page controls on keys")
|
|
889
|
+
if ctx.plan.use_strip:
|
|
890
|
+
deck.set_touchscreen_callback(_on_touch)
|
|
891
|
+
else:
|
|
892
|
+
logger.info("no touchscreen on this model -- skipping strip")
|
|
893
|
+
|
|
894
|
+
logger.info(
|
|
895
|
+
"Stream Deck active -- polling %s every %.1fs (sort=%s)",
|
|
896
|
+
hostname,
|
|
897
|
+
poll_interval,
|
|
898
|
+
sort_mode,
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
backoff = INITIAL_BACKOFF_SECONDS
|
|
902
|
+
shown_error_state: str | None = None
|
|
903
|
+
|
|
904
|
+
try:
|
|
905
|
+
while True:
|
|
906
|
+
if not deck.is_open() or not deck.connected():
|
|
907
|
+
logger.warning("Stream Deck disconnected")
|
|
908
|
+
return
|
|
909
|
+
|
|
910
|
+
try:
|
|
911
|
+
ctx.refresh()
|
|
912
|
+
except AuthError as exc:
|
|
913
|
+
reporter.update(server_connected=False, last_error=str(exc))
|
|
914
|
+
logger.critical(
|
|
915
|
+
"muxplex auth rejected -- check the federation key file: %s", exc
|
|
916
|
+
)
|
|
917
|
+
if shown_error_state != "auth":
|
|
918
|
+
_paint_status_only(deck, "AUTH FAILED -- check key file", ctx.plan)
|
|
919
|
+
ctx.invalidate_paint_cache()
|
|
920
|
+
shown_error_state = "auth"
|
|
921
|
+
if _interruptible_wait(deck, shutting_down, AUTH_RETRY_SECONDS):
|
|
922
|
+
return
|
|
923
|
+
continue
|
|
924
|
+
except UnreachableError as exc:
|
|
925
|
+
reporter.update(server_connected=False, last_error=str(exc))
|
|
926
|
+
logger.warning(
|
|
927
|
+
"muxplex unreachable: %s -- retrying in %.0fs", exc, backoff
|
|
928
|
+
)
|
|
929
|
+
if shown_error_state != "unreachable":
|
|
930
|
+
_paint_status_only(
|
|
931
|
+
deck, f"{hostname} UNREACHABLE -- retrying", ctx.plan
|
|
932
|
+
)
|
|
933
|
+
ctx.invalidate_paint_cache()
|
|
934
|
+
shown_error_state = "unreachable"
|
|
935
|
+
if _interruptible_wait(deck, shutting_down, backoff):
|
|
936
|
+
return
|
|
937
|
+
backoff = min(backoff * 2, MAX_BACKOFF_SECONDS)
|
|
938
|
+
continue
|
|
939
|
+
except MuxplexError as exc:
|
|
940
|
+
reporter.update(server_connected=False, last_error=str(exc))
|
|
941
|
+
logger.exception(
|
|
942
|
+
"unexpected muxplex API error; treating as unreachable"
|
|
943
|
+
)
|
|
944
|
+
if shown_error_state != "unreachable":
|
|
945
|
+
_paint_status_only(deck, f"{hostname} ERROR -- retrying", ctx.plan)
|
|
946
|
+
ctx.invalidate_paint_cache()
|
|
947
|
+
shown_error_state = "unreachable"
|
|
948
|
+
if _interruptible_wait(deck, shutting_down, backoff):
|
|
949
|
+
return
|
|
950
|
+
backoff = min(backoff * 2, MAX_BACKOFF_SECONDS)
|
|
951
|
+
continue
|
|
952
|
+
|
|
953
|
+
backoff = INITIAL_BACKOFF_SECONDS
|
|
954
|
+
shown_error_state = None
|
|
955
|
+
reporter.update(
|
|
956
|
+
server_connected=True,
|
|
957
|
+
last_poll_at=time.time(),
|
|
958
|
+
last_error=None,
|
|
959
|
+
active_session=ctx.active_session,
|
|
960
|
+
active_view=ctx.active_view,
|
|
961
|
+
page=ctx.pager.page,
|
|
962
|
+
)
|
|
963
|
+
if _interruptible_wait(deck, shutting_down, poll_interval):
|
|
964
|
+
return
|
|
965
|
+
finally:
|
|
966
|
+
reporter.update(
|
|
967
|
+
device_connected=False, device_caps=None, server_connected=False
|
|
968
|
+
)
|
|
969
|
+
_safe_close(deck)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _install_signal_handler() -> threading.Event:
|
|
973
|
+
shutting_down = threading.Event()
|
|
974
|
+
|
|
975
|
+
def handler(signum: int, frame: object) -> None:
|
|
976
|
+
shutting_down.set()
|
|
977
|
+
|
|
978
|
+
signal.signal(signal.SIGINT, handler)
|
|
979
|
+
signal.signal(signal.SIGTERM, handler)
|
|
980
|
+
return shutting_down
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def run(config: Config, manager: DeviceManager) -> int:
|
|
984
|
+
_configure_logging()
|
|
985
|
+
|
|
986
|
+
shutting_down = _install_signal_handler()
|
|
987
|
+
hostname = urlparse(config.server_url).hostname or config.server_url
|
|
988
|
+
logged_waiting = False
|
|
989
|
+
last_heartbeat = 0.0
|
|
990
|
+
|
|
991
|
+
# Published for `muxplex-deck status` to read -- see `.statusfile`'s
|
|
992
|
+
# docstring for why a running sidecar publishes its own status instead
|
|
993
|
+
# of `status` probing the (possibly exclusively-held) device directly.
|
|
994
|
+
reporter = StatusReporter(config.server_url)
|
|
995
|
+
|
|
996
|
+
logger.info("muxplex-deck starting (server=%s)", config.server_url)
|
|
997
|
+
try:
|
|
998
|
+
while not shutting_down.is_set():
|
|
999
|
+
try:
|
|
1000
|
+
deck = _find_deck(manager)
|
|
1001
|
+
except Exception:
|
|
1002
|
+
logger.exception(
|
|
1003
|
+
"Unexpected error while enumerating Stream Deck devices; will retry"
|
|
1004
|
+
)
|
|
1005
|
+
shutting_down.wait(DEVICE_POLL_SECONDS)
|
|
1006
|
+
continue
|
|
1007
|
+
|
|
1008
|
+
if deck is None:
|
|
1009
|
+
reporter.update(
|
|
1010
|
+
device_connected=False, device_caps=None, server_connected=False
|
|
1011
|
+
)
|
|
1012
|
+
now = time.monotonic()
|
|
1013
|
+
if not logged_waiting:
|
|
1014
|
+
logger.info(
|
|
1015
|
+
"waiting for a Stream Deck (polling every %.0fs)...",
|
|
1016
|
+
DEVICE_POLL_SECONDS,
|
|
1017
|
+
)
|
|
1018
|
+
logged_waiting = True
|
|
1019
|
+
last_heartbeat = now
|
|
1020
|
+
elif now - last_heartbeat >= ABSENT_HEARTBEAT_SECONDS:
|
|
1021
|
+
logger.info("still waiting for a Stream Deck...")
|
|
1022
|
+
last_heartbeat = now
|
|
1023
|
+
shutting_down.wait(DEVICE_POLL_SECONDS)
|
|
1024
|
+
continue
|
|
1025
|
+
|
|
1026
|
+
logged_waiting = False
|
|
1027
|
+
try:
|
|
1028
|
+
deck.open()
|
|
1029
|
+
except Exception:
|
|
1030
|
+
logger.exception("Failed to open Stream Deck device; will retry")
|
|
1031
|
+
shutting_down.wait(DEVICE_POLL_SECONDS)
|
|
1032
|
+
continue
|
|
1033
|
+
|
|
1034
|
+
try:
|
|
1035
|
+
with MuxplexClient(
|
|
1036
|
+
config.server_url, config.federation_key, ca_file=config.ca_file
|
|
1037
|
+
) as client:
|
|
1038
|
+
_run_active(
|
|
1039
|
+
deck,
|
|
1040
|
+
client,
|
|
1041
|
+
shutting_down,
|
|
1042
|
+
config.poll_interval,
|
|
1043
|
+
hostname,
|
|
1044
|
+
config.sort,
|
|
1045
|
+
config.focus_app,
|
|
1046
|
+
reporter,
|
|
1047
|
+
)
|
|
1048
|
+
except Exception:
|
|
1049
|
+
logger.exception("Unexpected error during active session; recovering")
|
|
1050
|
+
reporter.update(
|
|
1051
|
+
device_connected=False, device_caps=None, server_connected=False
|
|
1052
|
+
)
|
|
1053
|
+
_safe_close(deck)
|
|
1054
|
+
shutting_down.wait(DEVICE_POLL_SECONDS)
|
|
1055
|
+
finally:
|
|
1056
|
+
logger.info("muxplex-deck shutting down")
|
|
1057
|
+
|
|
1058
|
+
return 0
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _build_manager(*, emulator: bool, emulator_port: int) -> DeviceManager:
|
|
1062
|
+
"""Construct the backend's device manager -- the only backend-selection point.
|
|
1063
|
+
|
|
1064
|
+
Real and emulator backends are imported lazily, here, so choosing
|
|
1065
|
+
`--emulator` never imports (and thus never risks constructing) the
|
|
1066
|
+
hidapi-dependent real backend, and vice versa.
|
|
1067
|
+
"""
|
|
1068
|
+
if emulator:
|
|
1069
|
+
from .emulator import EmulatorDeviceManager
|
|
1070
|
+
|
|
1071
|
+
return EmulatorDeviceManager(emulator_port)
|
|
1072
|
+
|
|
1073
|
+
from .device_real import RealDeviceManager
|
|
1074
|
+
|
|
1075
|
+
return RealDeviceManager()
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def main() -> None:
|
|
1079
|
+
"""Legacy direct entry point (`python -m muxplex_deck.main`).
|
|
1080
|
+
|
|
1081
|
+
The actual CLI (subcommands, argument parsing, default-action dispatch)
|
|
1082
|
+
lives in `cli.py` now -- delegating here keeps argument parsing
|
|
1083
|
+
single-sourced instead of drifting across two copies. The console-script
|
|
1084
|
+
entry point (`muxplex-deck`) points at `cli:main` directly; this
|
|
1085
|
+
function exists only for anyone still invoking this module by path.
|
|
1086
|
+
"""
|
|
1087
|
+
from .cli import main as cli_main
|
|
1088
|
+
|
|
1089
|
+
cli_main()
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
if __name__ == "__main__":
|
|
1093
|
+
main()
|