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.
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()