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/emulator.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""In-process Stream Deck+ emulator: virtual device + localhost HTTP control UI.
|
|
2
|
+
|
|
3
|
+
Emulates at the `device.DeckDevice` seam, not at USB/HID level -- the
|
|
4
|
+
physical HID layer is already hardware-proven by `deck_probe`. This module
|
|
5
|
+
makes everything *above* that layer (state machine, muxplex client,
|
|
6
|
+
rendering, interaction flow) fully real and testable with zero hardware:
|
|
7
|
+
no `streamdeck` library import, no hidapi, no `DeviceManager` construction.
|
|
8
|
+
|
|
9
|
+
`EmulatorDeviceManager` starts a background HTTP server (stdlib
|
|
10
|
+
`http.server`, threaded) that serves a small control UI and the same JSON
|
|
11
|
+
endpoints the UI itself polls/posts -- so a human on a Mac and an agent
|
|
12
|
+
running curl/httpx see identical behavior. The server runs for the whole
|
|
13
|
+
sidecar process lifetime: it *is* the virtual USB bus. "Unplug" and "plug"
|
|
14
|
+
just toggle a flag on the one virtual device; they don't tear the server
|
|
15
|
+
down, mirroring how a real USB bus stays powered while a cable is pulled.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
from urllib.parse import urlparse
|
|
26
|
+
|
|
27
|
+
from PIL import Image
|
|
28
|
+
|
|
29
|
+
from .device import (
|
|
30
|
+
DeckDevice,
|
|
31
|
+
DeviceProbeError,
|
|
32
|
+
DialCallback,
|
|
33
|
+
DialEventType,
|
|
34
|
+
KeyCallback,
|
|
35
|
+
TouchCallback,
|
|
36
|
+
TouchscreenEventType,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = ["EmulatorDevice", "EmulatorDeviceManager"]
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("muxplex_deck")
|
|
42
|
+
|
|
43
|
+
KEY_COUNT = 8
|
|
44
|
+
KEY_LAYOUT = (2, 4) # rows x cols, same as the real Stream Deck+
|
|
45
|
+
DIAL_COUNT = 4
|
|
46
|
+
KEY_SIZE = (120, 120)
|
|
47
|
+
STRIP_SIZE = (800, 100)
|
|
48
|
+
DEFAULT_PORT = 8484
|
|
49
|
+
# Real Elgato vendor id + the real Stream Deck+ USB product id (from the
|
|
50
|
+
# `streamdeck` library's own `USBVendorIDs`/`USBProductIDs`), so a capability
|
|
51
|
+
# report against the emulator shows the same "usb id" a real Stream Deck+
|
|
52
|
+
# would -- there's no meaningful "emulated" vendor/product id to invent, and
|
|
53
|
+
# this keeps `describe_capabilities()` output consistent between backends.
|
|
54
|
+
VENDOR_ID_ELGATO = 0x0FD9
|
|
55
|
+
PRODUCT_ID_STREAMDECK_PLUS = 0x84
|
|
56
|
+
# Simulates real hardware's dim firmware power-on default (real-hardware
|
|
57
|
+
# feedback: the deck never asserted brightness itself and stayed dim) --
|
|
58
|
+
# NOT the sidecar's target brightness. `main._run_active` calls
|
|
59
|
+
# `deck.set_brightness(FULL_BRIGHTNESS_PERCENT)` on every bring-up; starting
|
|
60
|
+
# the emulator below that value makes the call's effect observable via
|
|
61
|
+
# `snapshot_state()["brightness"]`.
|
|
62
|
+
INITIAL_BRIGHTNESS_PERCENT = 40
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _encode_jpeg(image: Image.Image) -> bytes:
|
|
66
|
+
buf = io.BytesIO()
|
|
67
|
+
image.save(buf, "JPEG", quality=100)
|
|
68
|
+
return buf.getvalue()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _blank_key_jpeg() -> bytes:
|
|
72
|
+
return _encode_jpeg(Image.new("RGB", KEY_SIZE, "black"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _blank_strip_jpeg() -> bytes:
|
|
76
|
+
return _encode_jpeg(Image.new("RGB", STRIP_SIZE, "black"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EmulatorDevice:
|
|
80
|
+
"""A virtual Stream Deck+ satisfying the `DeckDevice` protocol.
|
|
81
|
+
|
|
82
|
+
Same image formats (8x 120x120 JPEG keys, 800x100 JPEG touch strip) as
|
|
83
|
+
the real StreamDeckPlus, so `rendering.py`'s `PILHelper`-based drawing
|
|
84
|
+
works completely unchanged. `plugged` models the virtual USB cable --
|
|
85
|
+
`connected()` reflects it directly; `is_open()` reflects our own
|
|
86
|
+
open()/close() calls, independent of the cable, exactly like the real
|
|
87
|
+
transport.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self) -> None:
|
|
91
|
+
self._lock = threading.RLock()
|
|
92
|
+
self._is_open = False
|
|
93
|
+
self.plugged = False
|
|
94
|
+
self._brightness = INITIAL_BRIGHTNESS_PERCENT
|
|
95
|
+
self._key_images: list[bytes] = [_blank_key_jpeg() for _ in range(KEY_COUNT)]
|
|
96
|
+
self._strip_image: bytes = _blank_strip_jpeg()
|
|
97
|
+
self._key_callback: KeyCallback | None = None
|
|
98
|
+
self._dial_callback: DialCallback | None = None
|
|
99
|
+
self._touch_callback: TouchCallback | None = None
|
|
100
|
+
|
|
101
|
+
# --- DeckDevice protocol -------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def open(self) -> None:
|
|
104
|
+
with self._lock:
|
|
105
|
+
self._is_open = True
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
with self._lock:
|
|
109
|
+
self._is_open = False
|
|
110
|
+
|
|
111
|
+
def reset(self) -> None:
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._key_images = [_blank_key_jpeg() for _ in range(KEY_COUNT)]
|
|
114
|
+
self._strip_image = _blank_strip_jpeg()
|
|
115
|
+
|
|
116
|
+
def is_open(self) -> bool:
|
|
117
|
+
return self._is_open
|
|
118
|
+
|
|
119
|
+
def connected(self) -> bool:
|
|
120
|
+
return self.plugged
|
|
121
|
+
|
|
122
|
+
def key_count(self) -> int:
|
|
123
|
+
return KEY_COUNT
|
|
124
|
+
|
|
125
|
+
def key_layout(self) -> tuple[int, int]:
|
|
126
|
+
return KEY_LAYOUT
|
|
127
|
+
|
|
128
|
+
def dial_count(self) -> int:
|
|
129
|
+
return DIAL_COUNT
|
|
130
|
+
|
|
131
|
+
def is_touch(self) -> bool:
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def touch_key_count(self) -> int:
|
|
135
|
+
# The Plus's touch surface is the 800x100 touchscreen strip, not
|
|
136
|
+
# discrete touch buttons (that's the Neo) -- zero, same as the
|
|
137
|
+
# real Stream Deck+ reports.
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
def is_visual(self) -> bool:
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
def vendor_id(self) -> int:
|
|
144
|
+
return VENDOR_ID_ELGATO
|
|
145
|
+
|
|
146
|
+
def product_id(self) -> int:
|
|
147
|
+
return PRODUCT_ID_STREAMDECK_PLUS
|
|
148
|
+
|
|
149
|
+
def deck_type(self) -> str:
|
|
150
|
+
return "Stream Deck + (emulated)"
|
|
151
|
+
|
|
152
|
+
def get_serial_number(self) -> str:
|
|
153
|
+
return "EMULATED-0001"
|
|
154
|
+
|
|
155
|
+
def get_firmware_version(self) -> str:
|
|
156
|
+
return "emulator-1.0"
|
|
157
|
+
|
|
158
|
+
def key_image_format(self) -> dict:
|
|
159
|
+
return {
|
|
160
|
+
"size": KEY_SIZE,
|
|
161
|
+
"format": "JPEG",
|
|
162
|
+
"flip": (False, False),
|
|
163
|
+
"rotation": 0,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def touchscreen_image_format(self) -> dict:
|
|
167
|
+
return {
|
|
168
|
+
"size": STRIP_SIZE,
|
|
169
|
+
"format": "JPEG",
|
|
170
|
+
"flip": (False, False),
|
|
171
|
+
"rotation": 0,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def set_brightness(self, percent: float) -> None:
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._brightness = max(0, min(100, int(percent)))
|
|
177
|
+
|
|
178
|
+
def set_key_image(self, key: int, image: bytes) -> None:
|
|
179
|
+
with self._lock:
|
|
180
|
+
self._key_images[key] = image
|
|
181
|
+
|
|
182
|
+
def set_touchscreen_image(
|
|
183
|
+
self,
|
|
184
|
+
image: bytes,
|
|
185
|
+
x_pos: int = 0,
|
|
186
|
+
y_pos: int = 0,
|
|
187
|
+
width: int = 0,
|
|
188
|
+
height: int = 0,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Paint a region of the touch strip.
|
|
191
|
+
|
|
192
|
+
Mirrors the real library's own validation: a non-empty image with a
|
|
193
|
+
zero-size region is invalid (this is the exact bug fixed in
|
|
194
|
+
`rendering._paint_full_touchscreen` -- see its docstring). Anything
|
|
195
|
+
else is composited onto the current strip image, so partial-zone
|
|
196
|
+
updates (like `deck_probe`'s dial counters) behave the same as on
|
|
197
|
+
real hardware.
|
|
198
|
+
"""
|
|
199
|
+
with self._lock:
|
|
200
|
+
if not image:
|
|
201
|
+
self._strip_image = _blank_strip_jpeg()
|
|
202
|
+
return
|
|
203
|
+
if width <= 0 or height <= 0:
|
|
204
|
+
raise IndexError(f"Invalid draw width {width}")
|
|
205
|
+
if (x_pos, y_pos, (width, height)) == (0, 0, STRIP_SIZE):
|
|
206
|
+
self._strip_image = image
|
|
207
|
+
return
|
|
208
|
+
base = Image.open(io.BytesIO(self._strip_image)).convert("RGB")
|
|
209
|
+
patch = Image.open(io.BytesIO(image)).convert("RGB")
|
|
210
|
+
base.paste(patch, (x_pos, y_pos))
|
|
211
|
+
self._strip_image = _encode_jpeg(base)
|
|
212
|
+
|
|
213
|
+
def set_key_callback(self, callback: KeyCallback | None) -> None:
|
|
214
|
+
with self._lock:
|
|
215
|
+
self._key_callback = callback
|
|
216
|
+
|
|
217
|
+
def set_dial_callback(self, callback: DialCallback | None) -> None:
|
|
218
|
+
with self._lock:
|
|
219
|
+
self._dial_callback = callback
|
|
220
|
+
|
|
221
|
+
def set_touchscreen_callback(self, callback: TouchCallback | None) -> None:
|
|
222
|
+
with self._lock:
|
|
223
|
+
self._touch_callback = callback
|
|
224
|
+
|
|
225
|
+
def __enter__(self) -> None:
|
|
226
|
+
self._lock.acquire()
|
|
227
|
+
|
|
228
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
229
|
+
self._lock.release()
|
|
230
|
+
|
|
231
|
+
# --- HTTP-facing helpers (not part of DeckDevice) -------------------------
|
|
232
|
+
|
|
233
|
+
def snapshot_state(self) -> dict:
|
|
234
|
+
with self._lock:
|
|
235
|
+
return {
|
|
236
|
+
"plugged": self.plugged,
|
|
237
|
+
"is_open": self._is_open,
|
|
238
|
+
"brightness": self._brightness,
|
|
239
|
+
"key_count": KEY_COUNT,
|
|
240
|
+
"dial_count": DIAL_COUNT,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def key_image(self, index: int) -> bytes:
|
|
244
|
+
with self._lock:
|
|
245
|
+
return self._key_images[index]
|
|
246
|
+
|
|
247
|
+
def strip_image(self) -> bytes:
|
|
248
|
+
with self._lock:
|
|
249
|
+
return self._strip_image
|
|
250
|
+
|
|
251
|
+
def fire_key(self, key: int, pressed: bool) -> None:
|
|
252
|
+
callback = self._key_callback
|
|
253
|
+
if callback is not None:
|
|
254
|
+
callback(self, key, pressed)
|
|
255
|
+
|
|
256
|
+
def fire_dial(self, dial: int, event_type: DialEventType, value: object) -> None:
|
|
257
|
+
callback = self._dial_callback
|
|
258
|
+
if callback is not None:
|
|
259
|
+
callback(self, dial, event_type, value)
|
|
260
|
+
|
|
261
|
+
def fire_touch(self, event_type: TouchscreenEventType, value: dict) -> None:
|
|
262
|
+
callback = self._touch_callback
|
|
263
|
+
if callback is not None:
|
|
264
|
+
callback(self, event_type, value)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
_UI_HTML = """<!doctype html>
|
|
268
|
+
<html>
|
|
269
|
+
<head>
|
|
270
|
+
<meta charset="utf-8">
|
|
271
|
+
<title>muxplex-deck emulator</title>
|
|
272
|
+
<style>
|
|
273
|
+
body { font-family: monospace; background: #111; color: #eee; }
|
|
274
|
+
#keys { display: grid; grid-template-columns: repeat(4, 120px); gap: 8px; }
|
|
275
|
+
#keys img { width: 120px; height: 120px; cursor: pointer; border: 1px solid #555; }
|
|
276
|
+
#strip img { width: 800px; height: 100px; border: 1px solid #555; margin-top: 8px; cursor: crosshair; }
|
|
277
|
+
button { margin: 2px; }
|
|
278
|
+
</style>
|
|
279
|
+
</head>
|
|
280
|
+
<body>
|
|
281
|
+
<h3>muxplex-deck emulator</h3>
|
|
282
|
+
<div id="status">loading...</div>
|
|
283
|
+
<button onclick="plug()">Plug in</button>
|
|
284
|
+
<button onclick="unplug()">Unplug</button>
|
|
285
|
+
<div id="keys"></div>
|
|
286
|
+
<div id="strip"><img id="stripimg" src="/strip.jpg"></div>
|
|
287
|
+
<h4>Dials</h4>
|
|
288
|
+
<div id="dials"></div>
|
|
289
|
+
<script>
|
|
290
|
+
const KEY_COUNT = 8;
|
|
291
|
+
const DIAL_COUNT = 4;
|
|
292
|
+
|
|
293
|
+
function buildKeys() {
|
|
294
|
+
const div = document.getElementById('keys');
|
|
295
|
+
for (let i = 0; i < KEY_COUNT; i++) {
|
|
296
|
+
const img = document.createElement('img');
|
|
297
|
+
img.id = 'key' + i;
|
|
298
|
+
img.src = '/keys/' + i + '.jpg';
|
|
299
|
+
img.onclick = () => clickKey(i);
|
|
300
|
+
div.appendChild(img);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function buildDials() {
|
|
305
|
+
const div = document.getElementById('dials');
|
|
306
|
+
for (let i = 0; i < DIAL_COUNT; i++) {
|
|
307
|
+
const row = document.createElement('div');
|
|
308
|
+
row.innerHTML = 'dial ' + i + ': ' +
|
|
309
|
+
'<button onclick="turnDial(' + i + ', -1)">-</button>' +
|
|
310
|
+
'<button onclick="turnDial(' + i + ', 1)">+</button>' +
|
|
311
|
+
'<button onclick="pushDial(' + i + ')">push</button>';
|
|
312
|
+
div.appendChild(row);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function post(path, body) {
|
|
317
|
+
await fetch(path, { method: 'POST', body: JSON.stringify(body || {}) });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function clickKey(i) { await post('/input/key', { key: i, action: 'click' }); }
|
|
321
|
+
async function turnDial(i, ticks) { await post('/input/dial', { dial: i, action: 'turn', ticks: ticks }); }
|
|
322
|
+
async function pushDial(i) {
|
|
323
|
+
await post('/input/dial', { dial: i, action: 'push', pressed: true });
|
|
324
|
+
await post('/input/dial', { dial: i, action: 'push', pressed: false });
|
|
325
|
+
}
|
|
326
|
+
async function plug() { await post('/plug'); }
|
|
327
|
+
async function unplug() { await post('/unplug'); }
|
|
328
|
+
|
|
329
|
+
async function refresh() {
|
|
330
|
+
const t = Date.now();
|
|
331
|
+
for (let i = 0; i < KEY_COUNT; i++) {
|
|
332
|
+
document.getElementById('key' + i).src = '/keys/' + i + '.jpg?t=' + t;
|
|
333
|
+
}
|
|
334
|
+
document.getElementById('stripimg').src = '/strip.jpg?t=' + t;
|
|
335
|
+
try {
|
|
336
|
+
const res = await fetch('/state');
|
|
337
|
+
const state = await res.json();
|
|
338
|
+
document.getElementById('status').textContent =
|
|
339
|
+
'plugged=' + state.plugged + ' open=' + state.is_open +
|
|
340
|
+
' brightness=' + state.brightness + '% keys=' + state.key_count;
|
|
341
|
+
} catch (e) {
|
|
342
|
+
document.getElementById('status').textContent = 'server unreachable?';
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
document.getElementById('stripimg').onclick = (e) => {
|
|
347
|
+
const rect = e.target.getBoundingClientRect();
|
|
348
|
+
const x = Math.round((e.clientX - rect.left) * (800 / rect.width));
|
|
349
|
+
const y = Math.round((e.clientY - rect.top) * (100 / rect.height));
|
|
350
|
+
post('/input/touch', { type: 'short', x: x, y: y });
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
buildKeys();
|
|
354
|
+
buildDials();
|
|
355
|
+
refresh();
|
|
356
|
+
setInterval(refresh, 400);
|
|
357
|
+
</script>
|
|
358
|
+
</body>
|
|
359
|
+
</html>
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class _EmulatorHandler(BaseHTTPRequestHandler):
|
|
364
|
+
"""Serves the control UI + JSON/JPEG endpoints for one `EmulatorDevice`.
|
|
365
|
+
|
|
366
|
+
`device` and `manager` are class attributes, bound per-server by
|
|
367
|
+
`_bound_handler_class()` below -- `BaseHTTPRequestHandler` instances are
|
|
368
|
+
constructed by `HTTPServer` internals with a fixed signature, so this is
|
|
369
|
+
the standard way to hand instance-specific state to request handlers.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
device: EmulatorDevice
|
|
373
|
+
manager: EmulatorDeviceManager
|
|
374
|
+
|
|
375
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
376
|
+
pass # the sidecar's own logger already covers what matters
|
|
377
|
+
|
|
378
|
+
def _send_json(self, status: int, payload: dict) -> None:
|
|
379
|
+
body = json.dumps(payload).encode("utf-8")
|
|
380
|
+
self.send_response(status)
|
|
381
|
+
self.send_header("Content-Type", "application/json")
|
|
382
|
+
self.send_header("Content-Length", str(len(body)))
|
|
383
|
+
self.end_headers()
|
|
384
|
+
self.wfile.write(body)
|
|
385
|
+
|
|
386
|
+
def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
|
|
387
|
+
self.send_response(status)
|
|
388
|
+
self.send_header("Content-Type", content_type)
|
|
389
|
+
self.send_header("Content-Length", str(len(body)))
|
|
390
|
+
self.send_header("Cache-Control", "no-store")
|
|
391
|
+
self.end_headers()
|
|
392
|
+
self.wfile.write(body)
|
|
393
|
+
|
|
394
|
+
def _read_json(self) -> dict:
|
|
395
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
396
|
+
raw = self.rfile.read(length) if length else b""
|
|
397
|
+
return json.loads(raw) if raw else {}
|
|
398
|
+
|
|
399
|
+
def do_GET(self) -> None:
|
|
400
|
+
path = urlparse(self.path).path
|
|
401
|
+
if path == "/":
|
|
402
|
+
self._send_bytes(200, "text/html; charset=utf-8", _UI_HTML.encode("utf-8"))
|
|
403
|
+
return
|
|
404
|
+
if path == "/state":
|
|
405
|
+
self._send_json(200, self.device.snapshot_state())
|
|
406
|
+
return
|
|
407
|
+
if path == "/strip.jpg":
|
|
408
|
+
self._send_bytes(200, "image/jpeg", self.device.strip_image())
|
|
409
|
+
return
|
|
410
|
+
if path.startswith("/keys/") and path.endswith(".jpg"):
|
|
411
|
+
try:
|
|
412
|
+
index = int(path[len("/keys/") : -len(".jpg")])
|
|
413
|
+
self._send_bytes(200, "image/jpeg", self.device.key_image(index))
|
|
414
|
+
except (ValueError, IndexError):
|
|
415
|
+
self._send_json(404, {"error": "unknown key index"})
|
|
416
|
+
return
|
|
417
|
+
self._send_json(404, {"error": "not found"})
|
|
418
|
+
|
|
419
|
+
def do_POST(self) -> None:
|
|
420
|
+
path = urlparse(self.path).path
|
|
421
|
+
try:
|
|
422
|
+
if path == "/plug":
|
|
423
|
+
self.manager.plug()
|
|
424
|
+
self._send_json(200, self.device.snapshot_state())
|
|
425
|
+
elif path == "/unplug":
|
|
426
|
+
self.manager.unplug()
|
|
427
|
+
self._send_json(200, self.device.snapshot_state())
|
|
428
|
+
elif path == "/input/key":
|
|
429
|
+
self._handle_key_input(self._read_json())
|
|
430
|
+
self._send_json(200, {"ok": True})
|
|
431
|
+
elif path == "/input/dial":
|
|
432
|
+
self._handle_dial_input(self._read_json())
|
|
433
|
+
self._send_json(200, {"ok": True})
|
|
434
|
+
elif path == "/input/touch":
|
|
435
|
+
self._handle_touch_input(self._read_json())
|
|
436
|
+
self._send_json(200, {"ok": True})
|
|
437
|
+
else:
|
|
438
|
+
self._send_json(404, {"error": "not found"})
|
|
439
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
440
|
+
self._send_json(400, {"error": str(exc)})
|
|
441
|
+
|
|
442
|
+
def _handle_key_input(self, body: dict) -> None:
|
|
443
|
+
key = int(body["key"])
|
|
444
|
+
action = body.get("action", "click")
|
|
445
|
+
if action == "press":
|
|
446
|
+
self.device.fire_key(key, True)
|
|
447
|
+
elif action == "release":
|
|
448
|
+
self.device.fire_key(key, False)
|
|
449
|
+
elif action == "click":
|
|
450
|
+
self.device.fire_key(key, True)
|
|
451
|
+
self.device.fire_key(key, False)
|
|
452
|
+
else:
|
|
453
|
+
raise ValueError(f"unknown key action {action!r}")
|
|
454
|
+
|
|
455
|
+
def _handle_dial_input(self, body: dict) -> None:
|
|
456
|
+
dial = int(body["dial"])
|
|
457
|
+
action = body.get("action")
|
|
458
|
+
if action == "turn":
|
|
459
|
+
self.device.fire_dial(dial, DialEventType.TURN, int(body["ticks"]))
|
|
460
|
+
elif action == "push":
|
|
461
|
+
self.device.fire_dial(dial, DialEventType.PUSH, bool(body["pressed"]))
|
|
462
|
+
else:
|
|
463
|
+
raise ValueError(f"unknown dial action {action!r}")
|
|
464
|
+
|
|
465
|
+
def _handle_touch_input(self, body: dict) -> None:
|
|
466
|
+
kind = body["type"]
|
|
467
|
+
if kind == "short":
|
|
468
|
+
value = {"x": int(body["x"]), "y": int(body["y"])}
|
|
469
|
+
self.device.fire_touch(TouchscreenEventType.SHORT, value)
|
|
470
|
+
elif kind == "long":
|
|
471
|
+
value = {"x": int(body["x"]), "y": int(body["y"])}
|
|
472
|
+
self.device.fire_touch(TouchscreenEventType.LONG, value)
|
|
473
|
+
elif kind == "drag":
|
|
474
|
+
value = {
|
|
475
|
+
"x": int(body["x"]),
|
|
476
|
+
"y": int(body["y"]),
|
|
477
|
+
"x_out": int(body["x_out"]),
|
|
478
|
+
"y_out": int(body["y_out"]),
|
|
479
|
+
}
|
|
480
|
+
self.device.fire_touch(TouchscreenEventType.DRAG, value)
|
|
481
|
+
else:
|
|
482
|
+
raise ValueError(f"unknown touch type {kind!r}")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _bound_handler_class(
|
|
486
|
+
device: EmulatorDevice, manager: EmulatorDeviceManager
|
|
487
|
+
) -> type[_EmulatorHandler]:
|
|
488
|
+
"""Create a `_EmulatorHandler` subclass bound to one device+manager pair."""
|
|
489
|
+
return type(
|
|
490
|
+
"BoundEmulatorHandler",
|
|
491
|
+
(_EmulatorHandler,),
|
|
492
|
+
{"device": device, "manager": manager},
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class EmulatorDeviceManager:
|
|
497
|
+
"""Backend-level 'find device' seam for the emulator -- the virtual USB bus.
|
|
498
|
+
|
|
499
|
+
The HTTP server runs for the lifetime of this manager (i.e. the whole
|
|
500
|
+
sidecar process); unplug/plug toggle a flag on the single virtual
|
|
501
|
+
device rather than tearing the server down.
|
|
502
|
+
"""
|
|
503
|
+
|
|
504
|
+
def __init__(self, port: int = DEFAULT_PORT) -> None:
|
|
505
|
+
self.device = EmulatorDevice()
|
|
506
|
+
handler_class = _bound_handler_class(self.device, self)
|
|
507
|
+
try:
|
|
508
|
+
self._httpd = ThreadingHTTPServer(("127.0.0.1", port), handler_class)
|
|
509
|
+
except OSError as exc:
|
|
510
|
+
raise DeviceProbeError(
|
|
511
|
+
f"Could not bind emulator UI to 127.0.0.1:{port}: {exc}\n"
|
|
512
|
+
"Pass a different port with --emulator-port <N>."
|
|
513
|
+
) from exc
|
|
514
|
+
self.port = self._httpd.server_address[1]
|
|
515
|
+
self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
|
|
516
|
+
self._thread.start()
|
|
517
|
+
logger.info("emulator UI: http://127.0.0.1:%d", self.port)
|
|
518
|
+
|
|
519
|
+
def find_device(self) -> DeckDevice | None:
|
|
520
|
+
return self.device if self.device.plugged else None
|
|
521
|
+
|
|
522
|
+
def plug(self) -> None:
|
|
523
|
+
self.device.plugged = True
|
|
524
|
+
|
|
525
|
+
def unplug(self) -> None:
|
|
526
|
+
self.device.plugged = False
|
|
527
|
+
|
|
528
|
+
def shutdown(self) -> None:
|
|
529
|
+
self._httpd.shutdown()
|
|
530
|
+
self._httpd.server_close()
|
muxplex_deck/focus.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Bring the local muxplex PWA to the foreground (platform seam).
|
|
2
|
+
|
|
3
|
+
One public function, `focus_app(name)`, called from a background thread when
|
|
4
|
+
a key press switches the active session (see `main._ActiveRuntime`). It is
|
|
5
|
+
strictly best-effort: it never raises, never blocks longer than a short
|
|
6
|
+
timeout, and a failure here must never disturb the session switch itself.
|
|
7
|
+
|
|
8
|
+
Platform dispatch happens here and only here:
|
|
9
|
+
|
|
10
|
+
- macOS (``darwin``): implemented via ``open -a <name>`` (see
|
|
11
|
+
`_focus_macos` for why that beats ``osascript``).
|
|
12
|
+
- Everything else: logs ONE INFO line per process ("macOS-only for now")
|
|
13
|
+
and no-ops, keeping a clean seam for a Windows implementation later.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("muxplex_deck")
|
|
23
|
+
|
|
24
|
+
# `open -a` normally returns in well under a second; if it hangs, give up
|
|
25
|
+
# rather than tying up the connect worker thread.
|
|
26
|
+
FOCUS_TIMEOUT_SECONDS = 2.0
|
|
27
|
+
|
|
28
|
+
_unsupported_logged = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def macos_focus_command(name: str) -> list[str]:
|
|
32
|
+
"""The exact argv the macOS path runs to activate `name`.
|
|
33
|
+
|
|
34
|
+
``open -a`` (rather than ``osascript -e 'tell application ... to
|
|
35
|
+
activate'``) because it activates the app if running, launches it if
|
|
36
|
+
not, works for Chrome/Safari-installed PWAs (which are real ``.app``
|
|
37
|
+
bundles under e.g. ``~/Applications/Chrome Apps.localized/``), and
|
|
38
|
+
needs no AppleScript/Automation permission prompt. List-args, never a
|
|
39
|
+
shell, so the app name is passed verbatim.
|
|
40
|
+
"""
|
|
41
|
+
return ["open", "-a", name]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def focus_app(name: str) -> None:
|
|
45
|
+
"""Bring the named application to the foreground, best-effort.
|
|
46
|
+
|
|
47
|
+
Never raises. Empty `name` is a pure no-op (feature disabled). On
|
|
48
|
+
non-macOS platforms, logs one INFO line per process and no-ops.
|
|
49
|
+
"""
|
|
50
|
+
if not name:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if sys.platform == "darwin":
|
|
54
|
+
_focus_macos(name)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# TODO(windows): implement for the user's Windows box -- likely via
|
|
58
|
+
# pywin32 SetForegroundWindow or an `explorer.exe`/PowerShell shim.
|
|
59
|
+
global _unsupported_logged
|
|
60
|
+
if not _unsupported_logged:
|
|
61
|
+
logger.info(
|
|
62
|
+
"focus_app=%r is configured, but foreground focus is macOS-only "
|
|
63
|
+
"for now; ignoring",
|
|
64
|
+
name,
|
|
65
|
+
)
|
|
66
|
+
_unsupported_logged = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _focus_macos(name: str) -> None:
|
|
70
|
+
command = macos_focus_command(name)
|
|
71
|
+
logger.debug("focus: running %s", command)
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
command,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=FOCUS_TIMEOUT_SECONDS,
|
|
78
|
+
check=False,
|
|
79
|
+
)
|
|
80
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
81
|
+
logger.warning("focus: could not activate %r: %s", name, exc)
|
|
82
|
+
return
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
# Most common cause: the app name doesn't match any installed app
|
|
85
|
+
# ("Unable to find application named ..."). Warn with the real
|
|
86
|
+
# stderr so the user can fix the config, but never raise.
|
|
87
|
+
logger.warning(
|
|
88
|
+
"focus: %r failed (rc=%d): %s",
|
|
89
|
+
" ".join(command),
|
|
90
|
+
result.returncode,
|
|
91
|
+
result.stderr.strip() or "(no stderr)",
|
|
92
|
+
)
|