agentstreamdeck 2.1.1__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.
- agentstreamdeck-2.1.1.dist-info/METADATA +1013 -0
- agentstreamdeck-2.1.1.dist-info/RECORD +65 -0
- agentstreamdeck-2.1.1.dist-info/WHEEL +5 -0
- agentstreamdeck-2.1.1.dist-info/entry_points.txt +2 -0
- agentstreamdeck-2.1.1.dist-info/licenses/LICENSE +201 -0
- agentstreamdeck-2.1.1.dist-info/top_level.txt +1 -0
- ocdeck/__init__.py +1 -0
- ocdeck/__main__.py +307 -0
- ocdeck/alerts.py +120 -0
- ocdeck/appearance.py +133 -0
- ocdeck/appearance_io.py +70 -0
- ocdeck/art.py +181 -0
- ocdeck/assets/logos/OCTICONS-LICENSE.txt +21 -0
- ocdeck/assets/logos/claude.png +0 -0
- ocdeck/assets/logos/copilot.png +0 -0
- ocdeck/assets/logos/copilot.svg +1 -0
- ocdeck/assets/logos/cursor.png +0 -0
- ocdeck/assets/logos/gemini.png +0 -0
- ocdeck/assets/logos/opencode.png +0 -0
- ocdeck/assets/logos/sources.json +27 -0
- ocdeck/broker.py +273 -0
- ocdeck/common.py +82 -0
- ocdeck/device.py +247 -0
- ocdeck/diagnostics.py +227 -0
- ocdeck/errors.py +22 -0
- ocdeck/focus.py +156 -0
- ocdeck/hardware_check.py +66 -0
- ocdeck/harness.py +203 -0
- ocdeck/launcher.py +203 -0
- ocdeck/model.py +177 -0
- ocdeck/observability.py +57 -0
- ocdeck/runtime/plugins/core.mjs +122 -0
- ocdeck/runtime/plugins/harnesses/bridge.mjs +52 -0
- ocdeck/runtime/plugins/harnesses/hook.mjs +38 -0
- ocdeck/runtime/plugins/harnesses/install.mjs +83 -0
- ocdeck/runtime/plugins/harnesses/profiles.mjs +107 -0
- ocdeck/runtime/plugins/server.mjs +67 -0
- ocdeck/runtime/plugins/tui.mjs +44 -0
- ocdeck/runtime/scripts/Install-Harness.ps1 +20 -0
- ocdeck/runtime/scripts/Install.ps1 +79 -0
- ocdeck/runtime/scripts/Launch-Agent.bat +7 -0
- ocdeck/runtime/scripts/Launch-Claude.bat +7 -0
- ocdeck/runtime/scripts/Launch-Codex.bat +7 -0
- ocdeck/runtime/scripts/Launch-Copilot-VSCode.bat +7 -0
- ocdeck/runtime/scripts/Launch-Copilot.bat +7 -0
- ocdeck/runtime/scripts/Launch-Cursor.bat +7 -0
- ocdeck/runtime/scripts/Launch-Gemini.bat +7 -0
- ocdeck/runtime/scripts/Remove-Integration.ps1 +35 -0
- ocdeck/runtime/scripts/Run-OpenCode.ps1 +7 -0
- ocdeck/runtime/scripts/Test.ps1 +11 -0
- ocdeck/runtime/scripts/Uninstall.ps1 +11 -0
- ocdeck/runtime/scripts/Verify-Windows.ps1 +13 -0
- ocdeck/runtime/scripts/check-js.py +8 -0
- ocdeck/runtime/scripts/examples/Claude-Cloud.bat +6 -0
- ocdeck/runtime/scripts/examples/Claude-Local.bat +21 -0
- ocdeck/runtime/scripts/examples/HomeAILab-Claude-5090.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-Claude-Cluster.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-5090.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-Spark.bat +8 -0
- ocdeck/runtime/scripts/examples/OpenCode-Cloud.bat +8 -0
- ocdeck/runtime/scripts/render-gallery.py +94 -0
- ocdeck/security.py +38 -0
- ocdeck/settings.py +42 -0
- ocdeck/uninstall.py +100 -0
- ocdeck/updates.py +46 -0
ocdeck/broker.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from .observability import configure, tail, correlation
|
|
6
|
+
from .alerts import Alerts
|
|
7
|
+
from .security import scrub
|
|
8
|
+
import uuid
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import queue
|
|
12
|
+
import secrets
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.parse
|
|
16
|
+
|
|
17
|
+
from .common import alive, atomic_json, home, identity, read_json, load_config
|
|
18
|
+
from .device import DeviceLoop
|
|
19
|
+
from .focus import activate
|
|
20
|
+
from .model import Registry
|
|
21
|
+
from .errors import message
|
|
22
|
+
from .settings import validate_config
|
|
23
|
+
|
|
24
|
+
LOG = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InstanceLock:
|
|
28
|
+
def __init__(self, root):
|
|
29
|
+
self.path = root / "broker.lock"
|
|
30
|
+
|
|
31
|
+
def __enter__(self):
|
|
32
|
+
self.f = open(self.path, "a+b")
|
|
33
|
+
try:
|
|
34
|
+
self.f.seek(0)
|
|
35
|
+
if os.name == "nt":
|
|
36
|
+
import msvcrt
|
|
37
|
+
|
|
38
|
+
if not self.f.read(1):
|
|
39
|
+
self.f.write(b"0")
|
|
40
|
+
self.f.flush()
|
|
41
|
+
self.f.seek(0)
|
|
42
|
+
msvcrt.locking(self.f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
43
|
+
else:
|
|
44
|
+
import fcntl
|
|
45
|
+
|
|
46
|
+
fcntl.flock(self.f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
47
|
+
except Exception:
|
|
48
|
+
self.f.close()
|
|
49
|
+
raise
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *args):
|
|
53
|
+
self.f.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Broker:
|
|
57
|
+
def __init__(self, root=None, mock=False, probe=alive, focus=activate):
|
|
58
|
+
self.root = Path(root or home())
|
|
59
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
saved = load_config(self.root)
|
|
61
|
+
validate_config(saved)
|
|
62
|
+
self.config = {
|
|
63
|
+
"fps": 24,
|
|
64
|
+
"brightness": 45,
|
|
65
|
+
"animations": True,
|
|
66
|
+
"ready": True,
|
|
67
|
+
"check_updates": not mock,
|
|
68
|
+
**saved,
|
|
69
|
+
}
|
|
70
|
+
if not (self.root / "token").exists():
|
|
71
|
+
fd = os.open(self.root / "token", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
72
|
+
with os.fdopen(fd, "w", encoding="ascii") as f:
|
|
73
|
+
f.write(secrets.token_hex(32))
|
|
74
|
+
self.token = (self.root / "token").read_text(encoding="ascii").strip()
|
|
75
|
+
self.registry = Registry(probe, slots=self.config.get("slots", 6), secrets=(self.token,))
|
|
76
|
+
self.alerts = Alerts(self.config)
|
|
77
|
+
self.update = None
|
|
78
|
+
self.stop = threading.Event()
|
|
79
|
+
self.presses = queue.Queue(maxsize=64)
|
|
80
|
+
self.device = DeviceLoop(self.registry, self.presses, self.stop, self.config, mock)
|
|
81
|
+
self.focus = focus
|
|
82
|
+
self.last_focus = None
|
|
83
|
+
self.last_press = {}
|
|
84
|
+
self.server = None
|
|
85
|
+
|
|
86
|
+
def dispatch(self, method, path, body):
|
|
87
|
+
if method == "GET" and path == "/v1/status":
|
|
88
|
+
with self.registry.lock:
|
|
89
|
+
overflow = sum(r["slot"] is None for r in self.registry.records.values())
|
|
90
|
+
return {
|
|
91
|
+
"epoch": self.registry.epoch,
|
|
92
|
+
"device": dict(self.device.status),
|
|
93
|
+
"slots": self.registry.view(),
|
|
94
|
+
"overflow": overflow,
|
|
95
|
+
"lastFocus": scrub(self.last_focus, (self.token,)),
|
|
96
|
+
"brokerPid": os.getpid(),
|
|
97
|
+
"recentErrors": tail(self.root, 10, errors_only=True),
|
|
98
|
+
"update": self.update,
|
|
99
|
+
}
|
|
100
|
+
if method == "POST" and path == "/v1/register":
|
|
101
|
+
record = self.registry.upsert(body)
|
|
102
|
+
return {"epoch": self.registry.epoch, "slot": record["slot"]}
|
|
103
|
+
if path.startswith("/v1/instances/"):
|
|
104
|
+
key = urllib.parse.unquote(path[len("/v1/instances/") :])
|
|
105
|
+
if method == "PUT":
|
|
106
|
+
with self.registry.lock:
|
|
107
|
+
accepted = self.registry.snapshot(key, body)
|
|
108
|
+
if accepted:
|
|
109
|
+
self.alerts.observe(self.registry.view())
|
|
110
|
+
return {"accepted": accepted, "epoch": self.registry.epoch}
|
|
111
|
+
if method == "DELETE":
|
|
112
|
+
self.registry.remove(key)
|
|
113
|
+
return {"ok": True}
|
|
114
|
+
if method == "POST" and path == "/v1/focus":
|
|
115
|
+
return self.handle_press(body, synthetic=True)
|
|
116
|
+
if method == "POST" and path == "/v1/stop":
|
|
117
|
+
self.stop.set()
|
|
118
|
+
return {"ok": True}
|
|
119
|
+
raise KeyError("Unknown route")
|
|
120
|
+
|
|
121
|
+
def handle_press(self, view, synthetic=False):
|
|
122
|
+
r = self.registry.resolve(view.get("slot"), view.get("generation"), view.get("id"))
|
|
123
|
+
if not r:
|
|
124
|
+
return {"ok": False, "reason": "Empty or stale slot"}
|
|
125
|
+
now = time.monotonic()
|
|
126
|
+
if now - self.last_press.get(r["id"], -100) < 0.2:
|
|
127
|
+
return {"ok": False, "reason": "Debounced"}
|
|
128
|
+
self.last_press[r["id"]] = now
|
|
129
|
+
outcome = self.focus(r)
|
|
130
|
+
if not outcome.get("ok"):
|
|
131
|
+
outcome = {**outcome, "fix": message("AD006")}
|
|
132
|
+
self.last_focus = {**outcome, "id": r["id"], "synthetic": synthetic, "time": time.time()}
|
|
133
|
+
LOG.info("Focus %s", self.last_focus)
|
|
134
|
+
return self.last_focus
|
|
135
|
+
|
|
136
|
+
def check_update(self):
|
|
137
|
+
from .updates import check
|
|
138
|
+
|
|
139
|
+
self.update = check(self.root, self.config)
|
|
140
|
+
|
|
141
|
+
def serve(self):
|
|
142
|
+
broker = self
|
|
143
|
+
|
|
144
|
+
class Handler(BaseHTTPRequestHandler):
|
|
145
|
+
protocol_version = "HTTP/1.0"
|
|
146
|
+
|
|
147
|
+
def setup(self):
|
|
148
|
+
super().setup()
|
|
149
|
+
self.connection.settimeout(3)
|
|
150
|
+
|
|
151
|
+
def log_message(self, *args):
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def handle_request(self):
|
|
155
|
+
context = correlation.set(uuid.uuid4().hex)
|
|
156
|
+
try:
|
|
157
|
+
if self.headers.get("Origin"):
|
|
158
|
+
self.send_json(403, {"error": message("AD007", "Browser origins are not accepted")})
|
|
159
|
+
return
|
|
160
|
+
expected = "Bearer " + broker.token
|
|
161
|
+
if not hmac.compare_digest(self.headers.get("Authorization", ""), expected):
|
|
162
|
+
self.send_json(401, {"error": message("AD007")})
|
|
163
|
+
return
|
|
164
|
+
size = int(self.headers.get("Content-Length", "0"))
|
|
165
|
+
if not 0 <= size <= 65536:
|
|
166
|
+
self.send_json(413, {"error": message("AD004", "Body too large")})
|
|
167
|
+
return
|
|
168
|
+
raw = self.rfile.read(size)
|
|
169
|
+
if len(raw) != size:
|
|
170
|
+
raise ValueError("Incomplete body")
|
|
171
|
+
data = json.loads(raw) if raw else {}
|
|
172
|
+
if not isinstance(data, dict):
|
|
173
|
+
raise ValueError("Expected object")
|
|
174
|
+
result = broker.dispatch(self.command, urllib.parse.urlsplit(self.path).path, data)
|
|
175
|
+
self.send_json(200, result)
|
|
176
|
+
except KeyError:
|
|
177
|
+
self.send_json(404, {"error": message("AD005")})
|
|
178
|
+
except (ValueError, TypeError) as e:
|
|
179
|
+
self.send_json(400, {"error": message("AD004", str(e))})
|
|
180
|
+
except Exception:
|
|
181
|
+
LOG.exception(message("AD500"))
|
|
182
|
+
self.send_json(500, {"error": message("AD500")})
|
|
183
|
+
finally:
|
|
184
|
+
correlation.reset(context)
|
|
185
|
+
|
|
186
|
+
def send_json(self, code, value):
|
|
187
|
+
data = json.dumps(scrub(value, (broker.token,))).encode()
|
|
188
|
+
self.send_response(code)
|
|
189
|
+
self.send_header("Content-Type", "application/json")
|
|
190
|
+
self.send_header("Cache-Control", "no-store")
|
|
191
|
+
self.send_header("Content-Length", str(len(data)))
|
|
192
|
+
self.end_headers()
|
|
193
|
+
try:
|
|
194
|
+
self.wfile.write(data)
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
do_GET = do_POST = do_PUT = do_DELETE = handle_request
|
|
199
|
+
|
|
200
|
+
class Server(ThreadingHTTPServer):
|
|
201
|
+
daemon_threads = True
|
|
202
|
+
|
|
203
|
+
def __init__(self, *args):
|
|
204
|
+
self.limit = threading.BoundedSemaphore(16)
|
|
205
|
+
super().__init__(*args)
|
|
206
|
+
|
|
207
|
+
def process_request(self, sock, address):
|
|
208
|
+
if not self.limit.acquire(blocking=False):
|
|
209
|
+
sock.close()
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
super().process_request(sock, address)
|
|
213
|
+
except Exception:
|
|
214
|
+
self.limit.release()
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
def process_request_thread(self, *args):
|
|
218
|
+
try:
|
|
219
|
+
super().process_request_thread(*args)
|
|
220
|
+
finally:
|
|
221
|
+
self.limit.release()
|
|
222
|
+
|
|
223
|
+
self.server = Server(("127.0.0.1", 0), Handler)
|
|
224
|
+
atomic_json(
|
|
225
|
+
self.root / "discovery.json",
|
|
226
|
+
{"port": self.server.server_address[1], "epoch": self.registry.epoch, "process": identity()},
|
|
227
|
+
)
|
|
228
|
+
threads = [
|
|
229
|
+
threading.Thread(target=self.server.serve_forever, daemon=True),
|
|
230
|
+
threading.Thread(target=self.device.run, daemon=True),
|
|
231
|
+
threading.Thread(target=self.alerts.run, args=(self.stop,), daemon=True),
|
|
232
|
+
threading.Thread(target=self.check_update, daemon=True),
|
|
233
|
+
]
|
|
234
|
+
for thread in threads:
|
|
235
|
+
thread.start()
|
|
236
|
+
LOG.info("Broker ready on OS-assigned port %s", self.server.server_address[1])
|
|
237
|
+
try:
|
|
238
|
+
next_sweep = 0
|
|
239
|
+
while not self.stop.is_set():
|
|
240
|
+
if time.monotonic() >= next_sweep:
|
|
241
|
+
self.registry.sweep()
|
|
242
|
+
next_sweep = time.monotonic() + 0.5
|
|
243
|
+
try:
|
|
244
|
+
self.handle_press(self.presses.get(timeout=0.1))
|
|
245
|
+
except queue.Empty:
|
|
246
|
+
pass
|
|
247
|
+
except Exception:
|
|
248
|
+
LOG.exception(message("AD006"))
|
|
249
|
+
finally:
|
|
250
|
+
self.stop.set()
|
|
251
|
+
self.server.shutdown()
|
|
252
|
+
self.server.server_close()
|
|
253
|
+
for thread in threads:
|
|
254
|
+
thread.join(timeout=3)
|
|
255
|
+
discovery = read_json(self.root / "discovery.json", {})
|
|
256
|
+
if discovery.get("epoch") == self.registry.epoch:
|
|
257
|
+
(self.root / "discovery.json").unlink(missing_ok=True)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run(root=None, mock=False):
|
|
261
|
+
root = Path(root or home())
|
|
262
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
try:
|
|
264
|
+
with InstanceLock(root):
|
|
265
|
+
broker = Broker(root, mock=mock)
|
|
266
|
+
configure(root)
|
|
267
|
+
import signal
|
|
268
|
+
|
|
269
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
270
|
+
signal.signal(sig, lambda *_: broker.stop.set())
|
|
271
|
+
broker.serve()
|
|
272
|
+
except (BlockingIOError, PermissionError):
|
|
273
|
+
LOG.info(message("AD008"))
|
ocdeck/common.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tempfile
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def home():
|
|
9
|
+
return Path(os.environ.get("OCDECK_HOME", Path.home() / ".opencode-deck"))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def atomic_json(path, data):
|
|
13
|
+
path = Path(path)
|
|
14
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
fd, temp = tempfile.mkstemp(dir=path.parent, prefix=".write-")
|
|
16
|
+
try:
|
|
17
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
18
|
+
json.dump(data, f, indent=2)
|
|
19
|
+
os.replace(temp, path)
|
|
20
|
+
finally:
|
|
21
|
+
if os.path.exists(temp):
|
|
22
|
+
os.unlink(temp)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_json(path, default=None):
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
28
|
+
except (OSError, ValueError):
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config(root=None):
|
|
33
|
+
path = Path(root or home()) / "config.json"
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return {}
|
|
36
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
37
|
+
if not isinstance(value, dict):
|
|
38
|
+
raise ValueError("config.json must contain an object")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def request(method, path, data=None, root=None, timeout: float = 2):
|
|
43
|
+
root = Path(root or home())
|
|
44
|
+
discovery = read_json(root / "discovery.json")
|
|
45
|
+
if not discovery:
|
|
46
|
+
raise ConnectionError("Broker is not running; start its scheduled task.")
|
|
47
|
+
# Discovery cannot redirect a privileged local client to a remote URL.
|
|
48
|
+
port = int(discovery["port"])
|
|
49
|
+
if not 1 <= port <= 65535:
|
|
50
|
+
raise ValueError("Invalid broker port")
|
|
51
|
+
token = (root / "token").read_text(encoding="ascii").strip()
|
|
52
|
+
req = urllib.request.Request(
|
|
53
|
+
f"http://127.0.0.1:{port}{path}",
|
|
54
|
+
data=json.dumps(data).encode() if data is not None else None,
|
|
55
|
+
method=method,
|
|
56
|
+
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"},
|
|
57
|
+
)
|
|
58
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
59
|
+
return json.load(response)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def identity(pid=None):
|
|
63
|
+
import psutil
|
|
64
|
+
|
|
65
|
+
p = psutil.Process(pid or os.getpid())
|
|
66
|
+
return {"pid": p.pid, "created": p.create_time()}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def alive(process):
|
|
70
|
+
import psutil
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
p = psutil.Process(int(process["pid"]))
|
|
74
|
+
return (
|
|
75
|
+
abs(p.create_time() - float(process["created"])) < 0.01
|
|
76
|
+
and p.is_running()
|
|
77
|
+
and p.status() != psutil.STATUS_ZOMBIE
|
|
78
|
+
)
|
|
79
|
+
except psutil.AccessDenied:
|
|
80
|
+
return None
|
|
81
|
+
except (psutil.NoSuchProcess, KeyError, ValueError, TypeError):
|
|
82
|
+
return False
|
ocdeck/device.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""HID adapter using the pip hidapi wheel, avoiding a manual hidapi.dll install."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from .art import frame
|
|
8
|
+
from .errors import message
|
|
9
|
+
from .appearance import appearance, animation_phase, harness_id
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
|
|
12
|
+
LOG = logging.getLogger(__name__)
|
|
13
|
+
MINI_PIDS = {0x0063, 0x0090, 0x00B3, 0x00B8}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WheelTransport:
|
|
17
|
+
"""StreamDeck transport duck type backed by cython-hidapi's bundled library."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, info):
|
|
20
|
+
self.info, self.handle = info, None
|
|
21
|
+
self.lock = threading.RLock()
|
|
22
|
+
|
|
23
|
+
def open(self):
|
|
24
|
+
import hid
|
|
25
|
+
|
|
26
|
+
with self.lock:
|
|
27
|
+
if self.handle is None:
|
|
28
|
+
h = hid.device()
|
|
29
|
+
h.open_path(self.info["path"])
|
|
30
|
+
h.set_nonblocking(True)
|
|
31
|
+
self.handle = h
|
|
32
|
+
|
|
33
|
+
def close(self):
|
|
34
|
+
with self.lock:
|
|
35
|
+
if self.handle:
|
|
36
|
+
self.handle.close()
|
|
37
|
+
self.handle = None
|
|
38
|
+
|
|
39
|
+
def is_open(self):
|
|
40
|
+
return self.handle is not None
|
|
41
|
+
|
|
42
|
+
def connected(self):
|
|
43
|
+
import hid
|
|
44
|
+
|
|
45
|
+
return any(x["path"] == self.info["path"] for x in hid.enumerate(0x0FD9, self.product_id()))
|
|
46
|
+
|
|
47
|
+
def path(self):
|
|
48
|
+
return self.info["path"]
|
|
49
|
+
|
|
50
|
+
def vendor_id(self):
|
|
51
|
+
return self.info["vendor_id"]
|
|
52
|
+
|
|
53
|
+
def product_id(self):
|
|
54
|
+
return self.info["product_id"]
|
|
55
|
+
|
|
56
|
+
def _call(self, method, *args):
|
|
57
|
+
from StreamDeck.Transport.Transport import TransportError
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with self.lock:
|
|
61
|
+
if self.handle is None:
|
|
62
|
+
raise OSError("Device is closed")
|
|
63
|
+
return getattr(self.handle, method)(*args)
|
|
64
|
+
except (OSError, ValueError) as e:
|
|
65
|
+
raise TransportError(str(e)) from e
|
|
66
|
+
|
|
67
|
+
def write(self, payload):
|
|
68
|
+
result = self._call("write", bytes(payload))
|
|
69
|
+
if result != len(payload):
|
|
70
|
+
from StreamDeck.Transport.Transport import TransportError
|
|
71
|
+
|
|
72
|
+
raise TransportError(f"Short HID write: {result}/{len(payload)}")
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
def write_feature(self, payload):
|
|
76
|
+
return self._call("send_feature_report", bytes(payload))
|
|
77
|
+
|
|
78
|
+
def read_feature(self, report_id, length):
|
|
79
|
+
return bytes(self._call("get_feature_report", report_id, length))
|
|
80
|
+
|
|
81
|
+
def read(self, length):
|
|
82
|
+
value = self._call("read", length)
|
|
83
|
+
return bytes(value) if value else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def device_types():
|
|
87
|
+
from StreamDeck.Devices.StreamDeckMini import StreamDeckMini
|
|
88
|
+
from StreamDeck.Devices.StreamDeckOriginal import StreamDeckOriginal
|
|
89
|
+
from StreamDeck.Devices.StreamDeckOriginalV2 import StreamDeckOriginalV2
|
|
90
|
+
from StreamDeck.Devices.StreamDeckXL import StreamDeckXL
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
**{p: StreamDeckMini for p in MINI_PIDS},
|
|
94
|
+
0x60: StreamDeckOriginal,
|
|
95
|
+
**{p: StreamDeckOriginalV2 for p in (0x6D, 0x80, 0xA5, 0xB9)},
|
|
96
|
+
**{p: StreamDeckXL for p in (0x6C, 0x8F, 0xBA)},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def elgato_running():
|
|
101
|
+
import psutil
|
|
102
|
+
|
|
103
|
+
for process in psutil.process_iter(["name"]):
|
|
104
|
+
try:
|
|
105
|
+
if (process.info["name"] or "").lower() in ("streamdeck.exe", "stream deck.exe", "stream deck"):
|
|
106
|
+
return True
|
|
107
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
108
|
+
pass
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def enumerate_devices():
|
|
113
|
+
import hid
|
|
114
|
+
|
|
115
|
+
types = device_types()
|
|
116
|
+
result = []
|
|
117
|
+
for d in hid.enumerate(0x0FD9, 0):
|
|
118
|
+
if d["product_id"] in types:
|
|
119
|
+
deck = types[d["product_id"]](WheelTransport(d))
|
|
120
|
+
result.append({**d, "model": deck.deck_type(), "keys": deck.key_count()})
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def enumerate_minis():
|
|
125
|
+
import hid
|
|
126
|
+
|
|
127
|
+
return [d for d in hid.enumerate(0x0FD9, 0) if d["product_id"] in MINI_PIDS]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DeviceLoop:
|
|
131
|
+
def __init__(self, registry, presses, stop, config, mock=False):
|
|
132
|
+
self.registry, self.presses, self.stop, self.config, self.mock = registry, presses, stop, config, mock
|
|
133
|
+
self.status = {"online": False, "mock": mock, "error": "Not connected", "frames": 0}
|
|
134
|
+
self.presented: list[dict | None] = [None] * len(self.registry.slots)
|
|
135
|
+
self.presented_lock = threading.Lock()
|
|
136
|
+
self.deck = None
|
|
137
|
+
|
|
138
|
+
def press(self, key, state):
|
|
139
|
+
if not state or not 0 <= key < len(self.presented):
|
|
140
|
+
return
|
|
141
|
+
with self.presented_lock:
|
|
142
|
+
view = self.presented[key]
|
|
143
|
+
if view:
|
|
144
|
+
try:
|
|
145
|
+
self.presses.put_nowait(dict(view))
|
|
146
|
+
except queue.Full:
|
|
147
|
+
LOG.warning("Press queue full; dropping press")
|
|
148
|
+
|
|
149
|
+
def run(self):
|
|
150
|
+
from PIL import Image
|
|
151
|
+
|
|
152
|
+
while not self.stop.is_set():
|
|
153
|
+
try:
|
|
154
|
+
if not self.mock:
|
|
155
|
+
from StreamDeck.ImageHelpers import PILHelper
|
|
156
|
+
|
|
157
|
+
devices = enumerate_devices()
|
|
158
|
+
serial = self.config.get("serial")
|
|
159
|
+
if serial:
|
|
160
|
+
devices = [d for d in devices if d.get("serial_number") == serial]
|
|
161
|
+
if len(devices) != 1:
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"Found {len(devices)} matching decks; select serial in config.json if multiple"
|
|
164
|
+
)
|
|
165
|
+
if elgato_running() and not self.config.get("allow_elgato", False):
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
"AD001: Elgato is running. Quit Stream Deck from its tray menu, then run ocdeck doctor."
|
|
168
|
+
)
|
|
169
|
+
self.deck = device_types()[devices[0]["product_id"]](WheelTransport(devices[0]))
|
|
170
|
+
self.deck.open()
|
|
171
|
+
self.registry.resize(self.deck.key_count())
|
|
172
|
+
self.presented = [None] * self.deck.key_count()
|
|
173
|
+
self.deck.set_brightness(int(self.config.get("brightness", 45)))
|
|
174
|
+
blank = PILHelper.to_native_key_format(self.deck, Image.new("RGB", (80, 80), "black"))
|
|
175
|
+
for k in range(len(self.registry.slots)):
|
|
176
|
+
self.deck.set_key_image(k, blank)
|
|
177
|
+
self.deck.set_key_callback(lambda deck, key, state: self.press(key, state))
|
|
178
|
+
self.status.update(serial=devices[0].get("serial_number"), productId=devices[0]["product_id"])
|
|
179
|
+
self.status.update(online=True, error="", keys=len(self.registry.slots))
|
|
180
|
+
last, native = {}, OrderedDict()
|
|
181
|
+
styles = [appearance(self.config, k) for k in range(len(self.registry.slots))]
|
|
182
|
+
next_probe = time.monotonic() + 2
|
|
183
|
+
fps = max(1, min(30, int(self.config.get("fps", 24))))
|
|
184
|
+
while not self.stop.is_set():
|
|
185
|
+
start = time.monotonic()
|
|
186
|
+
if not self.mock and start >= next_probe:
|
|
187
|
+
assert self.deck is not None
|
|
188
|
+
if not self.deck.is_open() or not self.deck.connected():
|
|
189
|
+
raise OSError("Mini disconnected")
|
|
190
|
+
next_probe = start + 2
|
|
191
|
+
views = self.registry.view()
|
|
192
|
+
if not any(v["id"] for v in views) and self.config.get("ready", True):
|
|
193
|
+
views[0] = {**views[0], "state": "ready"}
|
|
194
|
+
for k, v in enumerate(views):
|
|
195
|
+
style = styles[k]
|
|
196
|
+
phase = animation_phase(start, style, self.config.get("animations", True))
|
|
197
|
+
key = (
|
|
198
|
+
v["state"],
|
|
199
|
+
v["label"],
|
|
200
|
+
k,
|
|
201
|
+
phase if v["state"] != "off" else 0,
|
|
202
|
+
(self.deck.key_image_format()["size"][0] if self.deck else 80),
|
|
203
|
+
style,
|
|
204
|
+
harness_id(v["label"], v.get("harness", "")),
|
|
205
|
+
v.get("detail", "") if "detail" in (style.primary, style.secondary) else "",
|
|
206
|
+
v.get("pending"),
|
|
207
|
+
)
|
|
208
|
+
# Assignment identity must refresh even when the pixels are identical.
|
|
209
|
+
if last.get(k) == key:
|
|
210
|
+
with self.presented_lock:
|
|
211
|
+
self.presented[k] = dict(v)
|
|
212
|
+
continue
|
|
213
|
+
if not self.mock:
|
|
214
|
+
assert self.deck is not None
|
|
215
|
+
if key not in native:
|
|
216
|
+
if len(native) >= 768:
|
|
217
|
+
native.popitem(last=False)
|
|
218
|
+
native[key] = PILHelper.to_native_key_format(self.deck, frame(*key))
|
|
219
|
+
native.move_to_end(key)
|
|
220
|
+
self.deck.set_key_image(k, native[key])
|
|
221
|
+
with self.presented_lock:
|
|
222
|
+
self.presented[k] = dict(v)
|
|
223
|
+
last[k] = key
|
|
224
|
+
self.status["frames"] += 1
|
|
225
|
+
self.stop.wait(max(0, 1 / fps - (time.monotonic() - start)))
|
|
226
|
+
except Exception as error:
|
|
227
|
+
LOG.warning(message("AD002", str(error)))
|
|
228
|
+
self.status.update(online=False, error=message("AD002", str(error)))
|
|
229
|
+
finally:
|
|
230
|
+
if self.deck:
|
|
231
|
+
try:
|
|
232
|
+
if self.stop.is_set():
|
|
233
|
+
from StreamDeck.ImageHelpers import PILHelper
|
|
234
|
+
|
|
235
|
+
blank = PILHelper.to_native_key_format(self.deck, Image.new("RGB", (80, 80), "black"))
|
|
236
|
+
for k in range(len(self.registry.slots)):
|
|
237
|
+
self.deck.set_key_image(k, blank)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
try:
|
|
241
|
+
self.deck.close()
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
self.deck = None
|
|
245
|
+
with self.presented_lock:
|
|
246
|
+
self.presented = [None] * len(self.registry.slots)
|
|
247
|
+
self.stop.wait(2)
|