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.
Files changed (65) hide show
  1. agentstreamdeck-2.1.1.dist-info/METADATA +1013 -0
  2. agentstreamdeck-2.1.1.dist-info/RECORD +65 -0
  3. agentstreamdeck-2.1.1.dist-info/WHEEL +5 -0
  4. agentstreamdeck-2.1.1.dist-info/entry_points.txt +2 -0
  5. agentstreamdeck-2.1.1.dist-info/licenses/LICENSE +201 -0
  6. agentstreamdeck-2.1.1.dist-info/top_level.txt +1 -0
  7. ocdeck/__init__.py +1 -0
  8. ocdeck/__main__.py +307 -0
  9. ocdeck/alerts.py +120 -0
  10. ocdeck/appearance.py +133 -0
  11. ocdeck/appearance_io.py +70 -0
  12. ocdeck/art.py +181 -0
  13. ocdeck/assets/logos/OCTICONS-LICENSE.txt +21 -0
  14. ocdeck/assets/logos/claude.png +0 -0
  15. ocdeck/assets/logos/copilot.png +0 -0
  16. ocdeck/assets/logos/copilot.svg +1 -0
  17. ocdeck/assets/logos/cursor.png +0 -0
  18. ocdeck/assets/logos/gemini.png +0 -0
  19. ocdeck/assets/logos/opencode.png +0 -0
  20. ocdeck/assets/logos/sources.json +27 -0
  21. ocdeck/broker.py +273 -0
  22. ocdeck/common.py +82 -0
  23. ocdeck/device.py +247 -0
  24. ocdeck/diagnostics.py +227 -0
  25. ocdeck/errors.py +22 -0
  26. ocdeck/focus.py +156 -0
  27. ocdeck/hardware_check.py +66 -0
  28. ocdeck/harness.py +203 -0
  29. ocdeck/launcher.py +203 -0
  30. ocdeck/model.py +177 -0
  31. ocdeck/observability.py +57 -0
  32. ocdeck/runtime/plugins/core.mjs +122 -0
  33. ocdeck/runtime/plugins/harnesses/bridge.mjs +52 -0
  34. ocdeck/runtime/plugins/harnesses/hook.mjs +38 -0
  35. ocdeck/runtime/plugins/harnesses/install.mjs +83 -0
  36. ocdeck/runtime/plugins/harnesses/profiles.mjs +107 -0
  37. ocdeck/runtime/plugins/server.mjs +67 -0
  38. ocdeck/runtime/plugins/tui.mjs +44 -0
  39. ocdeck/runtime/scripts/Install-Harness.ps1 +20 -0
  40. ocdeck/runtime/scripts/Install.ps1 +79 -0
  41. ocdeck/runtime/scripts/Launch-Agent.bat +7 -0
  42. ocdeck/runtime/scripts/Launch-Claude.bat +7 -0
  43. ocdeck/runtime/scripts/Launch-Codex.bat +7 -0
  44. ocdeck/runtime/scripts/Launch-Copilot-VSCode.bat +7 -0
  45. ocdeck/runtime/scripts/Launch-Copilot.bat +7 -0
  46. ocdeck/runtime/scripts/Launch-Cursor.bat +7 -0
  47. ocdeck/runtime/scripts/Launch-Gemini.bat +7 -0
  48. ocdeck/runtime/scripts/Remove-Integration.ps1 +35 -0
  49. ocdeck/runtime/scripts/Run-OpenCode.ps1 +7 -0
  50. ocdeck/runtime/scripts/Test.ps1 +11 -0
  51. ocdeck/runtime/scripts/Uninstall.ps1 +11 -0
  52. ocdeck/runtime/scripts/Verify-Windows.ps1 +13 -0
  53. ocdeck/runtime/scripts/check-js.py +8 -0
  54. ocdeck/runtime/scripts/examples/Claude-Cloud.bat +6 -0
  55. ocdeck/runtime/scripts/examples/Claude-Local.bat +21 -0
  56. ocdeck/runtime/scripts/examples/HomeAILab-Claude-5090.bat +8 -0
  57. ocdeck/runtime/scripts/examples/HomeAILab-Claude-Cluster.bat +8 -0
  58. ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-5090.bat +8 -0
  59. ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-Spark.bat +8 -0
  60. ocdeck/runtime/scripts/examples/OpenCode-Cloud.bat +8 -0
  61. ocdeck/runtime/scripts/render-gallery.py +94 -0
  62. ocdeck/security.py +38 -0
  63. ocdeck/settings.py +42 -0
  64. ocdeck/uninstall.py +100 -0
  65. 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)