-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpoly_core.py
More file actions
1859 lines (1667 loc) · 94.7 KB
/
Copy pathpoly_core.py
File metadata and controls
1859 lines (1667 loc) · 94.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Qt-free operational core of PolyKybdHost (headless-core plan, H1).
``PolyCore`` owns the device stack and all operational background work:
the :class:`HidWorker` thread and its periodics (reconnect probe, console
reads, daylight brightness), the overlay send/command jobs, the overlay
mapping + handler, the sunlight model, MRU persistence and the sleep
listener. It communicates results exclusively through observer callbacks —
``emit(name, payload)`` with JSON-serializable payloads (contracts in
:mod:`polyhost.core.events`).
Threading contract: observer callbacks fire on core/worker threads.
Clients marshal to their own loop — the Qt client forwards every event
verbatim into ``WorkerBridge.job_done`` (a queued signal), which is why
the event names match the GUI's existing dispatch.
This module (and everything it imports) must stay importable without
PyQt5 and without a display: window tracking (pywinctl) is imported
lazily and degrades to "off" with a warning (plan §5.4).
"""
import os
import pathlib
import sys
import threading
import time
from polyhost._version import __version__, __protocol__
# Imported for its side effect: installs Logger.debug_detailed (used by the
# device code, e.g. poly_kybd). The Qt GUI gets this via host.py's log_util
# import; the headless process and bare tests would otherwise hit
# 'Logger' object has no attribute 'debug_detailed'. log_util is Qt-free.
import polyhost.util.log_util # noqa: F401
from polyhost.core import events
from polyhost.core.decisions import decide_probe_publish, decide_reconnect_apply
from polyhost.device.poly_kybd import MIN_SUPPORTED_PROTOCOL
from polyhost.device.device_manager import DeviceManager
from polyhost.device.device_settings import DeviceSettings
from polyhost.device import hid_fw_up
from polyhost.device import hid_fontpack
from polyhost.device.hid_worker import HidWorker
from polyhost.device.poly_kybd import PolyKybd
from polyhost.handler.common import OverlayCommand
from polyhost.services import telemetry as telemetry_svc
from polyhost.services.sleep_listener import install_sleep_listener
from polyhost.services.sunlight_helper import Sunlight
from polyhost.settings import PolySettings
from polyhost.util.observable import Observable
RECONNECT_CYCLE_MSEC = 1000
# After an overlay/MRU send the keyboard goes deaf for a few hundred ms while it
# bridges the images/mapping to the slave half over UART, so a probe landing in
# that window gets an EMPTY REPLY (harmless — the debounce absorbs it, but it's
# log noise and a wasted query). Skip the probe for one cycle's worth of time
# after the last overlay activity; a genuine disconnect is still caught once the
# window lapses (sends stop, so the timestamp goes stale within this window).
OVERLAY_PROBE_COOLDOWN_S = 1.0
UPDATE_CYCLE_MSEC = 250
PERIODIC_10MIN_CYCLE_MSEC = 1000 * 60 * 10
NEW_WINDOW_ACCEPT_TIME_MSEC = 1000
_RES_DIR = pathlib.Path(__file__).parent.parent.resolve() / "res"
def get_overlay_path(filepath):
"""Absolute path of a shipped overlay template (polyhost/res/overlays)."""
return os.path.join(_RES_DIR, "overlays", filepath)
def strip_key_injection(lines):
"""Drop the ``press``/``release`` key-injection commands from a script.
Returns ``(kept_lines, dropped_count)``. Used to enforce that a non-debug
host never drives arbitrary keystrokes on the keyboard via a command file
or the ``commands.execute`` control RPC (see ``PolyCore.execute_commands``).
"""
kept = [ln for ln in lines
if ln.strip().split(" ", 1)[0] not in ("press", "release")]
return kept, len(lines) - len(kept)
def flash_progress_relay(emit, cancel, kind):
"""Build the ``(progress_cb, cancel_flag)`` pair every font-pack-transport
flash hands to its engine.
``cancel_flag`` is a one-element **list** because the flash engines poll it
by reference between chunks — a plain bool could never reach them — and the
only thing that ever raises it is a progress callback noticing the worker's
cancel Event (a supersede or a ``suspend()``). Getting that wiring wrong
fails silently: the flash simply becomes uncancellable, which is why it
lives in one place rather than being re-typed at each of the five call
sites. Takes ``emit`` rather than a core so it stays a plain function.
"""
cancel_flag = [False]
def _progress(pct, m):
if cancel.is_set():
cancel_flag[0] = True # relay supersede/suspend to the engine
emit("fontpack_flash_progress", {"pct": pct, "msg": m, "kind": kind})
return _progress, cancel_flag
class PolyCore(Observable):
"""Operational facade: commands in, events out. No Qt, no widgets."""
def __init__(self, log, ignore_version=False, start_worker=True,
apply_reconnect_in_core=False, allow_key_injection=False,
telemetry_mode="in-process"):
self.log = log
self.ignore_version = ignore_version
# SECURITY: the `press`/`release` script commands inject real keystrokes
# on the keyboard (firmware HID cmd 14 -> the keyboard types into the
# host's focused app). That is a demo/dev capability, so it is honoured
# only when the owning process runs in developer mode (--dev, or the
# persisted developer_mode setting). The
# firmware also NACKs cmd 14 unless DB_TOGG is on; this is the host half.
self.allow_key_injection = allow_key_injection
# When True (headless, no GUI to render), the reconnect periodic
# applies its own snapshot (state + post-connect + status_changed).
# The Qt client leaves this False and applies in _apply_reconnect_result.
self.apply_reconnect_in_core = apply_reconnect_in_core
# Connection state. `connected` means present AND protocol/version
# compatible (only the reconnect decision tree may set it).
# `device_present` means a device answers protocol-independent
# queries (GET_ID) — firmware flash/apply keys off this so a
# mismatched keyboard can always be updated.
self.connected = False
self.device_present = False
self.paused = False
# Newer-firmware policy (session-only, like ignore_version): when the
# keyboard's protocol is NEWER than this host, the user chooses in a dialog
# whether to connect fully ("ignore") or run restricted ("safe"). None =
# undecided (defaults to safe + prompts). Remembered for the session, keyed
# to the protocol it was chosen for so a re-flash re-asks.
self.safe_mode = False
self._newer_fw_policy = None
self._newer_fw_policy_proto = None
# Worker-side reconnect bookkeeping. `last_applied_connected` is the
# host's last APPLIED state: the worker reads it, the applying client
# writes it (a bool read/write is atomic under the GIL).
self.last_applied_connected = False
self._probe_fail_streak = 0
# monotonic timestamp of the last overlay/MRU send or enable/disable, so
# the reconnect probe can skip the keyboard's post-send deaf window.
self._last_overlay_activity = 0.0
# Firmware version (parsed) of the connected keyboard, for update checks.
self.kb_sw_version = None
# Set on a fresh connect; consumed by the first applied snapshot after
# it so the overlay state on the keyboard is cleared exactly once.
self.needs_overlay_reset = False
# Last OS value pushed to the keyboard (an OsType.value int, or None). The
# window-tracking tick re-asserts the local OS when local windows drive the
# display and the forwarder's OS when a remote-forwarded window is active,
# deduped against this so set_os only fires on an actual change.
self._last_pushed_os = None
# Re-entrancy guard for the font-pack auto-flash: True only while a flash
# is actually running, so a connection flap mid-flash can't start a second
# one — but it is cleared on completion, so each fresh connect (e.g. a
# physical reconnect after a wipe) re-checks and flashes any stale bundles.
# decide_stale_bundles keeps it self-terminating: once the device is
# current, a reconnect finds nothing to do.
self._fontpack_flash_in_progress = False
# Bundles whose last flash attempt genuinely failed: {slot index: message}.
# Re-flashed on the next pass regardless of the version comparison, because a
# bundle can report a failure and still read as current (see
# _fontpack_flash_bundles_job). In-memory only — a daemon restart re-reads the
# device versions anyway, and a persisted failure could outlive its cause.
self._fontpack_failed = {}
self.poly_settings = PolySettings()
self.device_settings = DeviceSettings()
self.keeb = PolyKybd(self.device_settings, self.poly_settings)
self.device_mgr = DeviceManager(self.device_settings)
self.device_mgr.add(self.keeb, "PolyKybd", is_primary=True)
if self.poly_settings.get("dev_mock_enabled"):
# Imported here, not at module top: the mock pulls in overlay_sim ->
# numpy, which is otherwise dead weight on the daemon's startup import
# path (the mock is only used when dev_mock_enabled is set).
from polyhost.device.poly_kybd_mock import PolyKybdMock
mock = PolyKybdMock(self.device_settings, f"{__version__}")
self.device_mgr.add(mock, "PolyKybdMock", is_primary=False)
self.log.info("Mock device added as secondary.")
connected = self.keeb.connect()
self.device_present = connected
self.device_mgr.connect_secondaries()
self.device_mgr.reset_all_caches()
if connected:
self.log.info("Connected to PolyKybd.")
else:
self.log.info("Not yet connected to PolyKybd...")
# Observers: each is a callable(name, payload). Callbacks must be
# fast and exception-safe from the caller's perspective; Observable
# isolates one raising observer from the rest and from this thread.
Observable.__init__(self, log)
# Overlay mapping + active-window handler. pywinctl hard-fails at
# import without a display, so the handler is created lazily and
# window tracking degrades to "off" (plan §5.4) — explicit overlay
# sends still work.
self.mapping = {}
self.overlay_handler = None
# Focused-browser active-tab URL, so overlays can key off the website
# (browser web-apps defeat window-title matching). Fed by the browser
# extension via the loopback report server below, and/or the macOS
# AppleScript fallback consulted inside current_url.
from polyhost.handler.browser_url_source import BrowserUrlSource
# Shared with the forwarder, which needs the same receiver+provider pair
# to put the URL on the wire — one implementation, so the two roles
# cannot drift. `on_change` re-drives an SPA route change (no title
# change, so the window tick would otherwise see nothing).
self.browser_url_source = BrowserUrlSource(
self.log, settings_get=self.poly_settings.get,
on_change=self._on_browser_url_changed)
self.load_overlay_mapping(str(_RES_DIR / "overlay-mapping.poly.yaml"))
self._create_overlay_handler()
self.browser_url_source.start()
self.sunlight = Sunlight(
self.poly_settings.get("brightness_allow_online_location_lookup"),
self.poly_settings.get("brightness_allow_online_irradiance_request"))
self.worker = HidWorker(log=self.log)
self.worker.add_periodic("reconnect", RECONNECT_CYCLE_MSEC / 1000.0,
self._reconnect_periodic)
self.worker.add_periodic("console", UPDATE_CYCLE_MSEC / 1000.0,
self._console_periodic)
self.worker.add_periodic("brightness", PERIODIC_10MIN_CYCLE_MSEC / 1000.0,
self._brightness_periodic)
# Persist the keyboard MRU just before the system sleeps (Linux/logind).
# The callback fires on the listener's daemon thread; save_mru only
# logs and enqueues a worker job, so that is safe. Installed after the
# worker exists so the callback always has a queue to submit to.
self._sleep_listener = install_sleep_listener(self.save_mru, self.log)
# Optional core-owned window-tracking tick (headless mode, H3). The Qt
# client drives tick_window_tracking() from its main-thread QTimer
# instead (pywinctl/macOS), so it never starts this.
self._tick_thread = None
self._tick_stop = threading.Event()
self._tick_lock = threading.Lock()
# Anonymous usage census. Created unconditionally (so `polyctl telemetry
# status/preview` answers even when it is off) but only *started* by
# start_telemetry(), which the owning host calls alongside worker.start()
# — so the many short-lived PolyCore instances in the test suite never
# spawn a thread.
self.telemetry = self._create_telemetry(telemetry_mode)
self.telemetry.note("sessions")
self._log_telemetry_notice()
if start_worker:
self.worker.start()
self.start_telemetry()
# ------------------------------------------------------------------
# Observer plumbing
# ------------------------------------------------------------------
# subscribe() / emit() come from Observable (polyhost/util/observable.py) —
# the same seam RemoteCore exposes, so PolyHost can consume either.
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def set_paused(self, paused):
"""Pause/resume all device traffic. Pausing drops the connection
state so the next resume goes through a full fresh-connect apply."""
self.paused = paused
if paused:
self.connected = False
self.last_applied_connected = False
# suspend() is idempotent, so toggling pause while already
# suspended (e.g. a flash holds exclusive()) is safe.
self.worker.suspend()
else:
self.worker.resume()
def set_newer_firmware_policy(self, choice):
"""Record the session choice for a keyboard whose firmware protocol is
NEWER than this host: "ignore" (connect fully) or "safe" (restricted).
Thread-agnostic (no Qt / no device I/O) — called in-process on the Qt main
thread or, in daemon mode, on a control-server thread over RPC. Forces a
prompt re-apply by dropping ``last_applied_connected`` so the next ~1 s
reconnect probe re-runs the decision tree with the new policy (a bool write,
atomic under the GIL; mirrors set_paused)."""
if choice not in ("ignore", "safe"):
return False, f"Unknown newer-firmware policy: {choice!r}"
self._newer_fw_policy = choice
self._newer_fw_policy_proto = self.keeb.get_protocol_version()
self.last_applied_connected = False
self.log.info("Newer-firmware policy set to '%s'.", choice)
return True, {"choice": choice}
def start_window_tracking(self, interval_s=UPDATE_CYCLE_MSEC / 1000.0):
"""Run the active-window tick on a core-owned daemon thread.
For headless mode (H3): there is no Qt main-thread QTimer to drive
``tick_window_tracking``. No-op when there is no window handler (no
display) — explicit overlay sends via the API still work. The Qt
client must NOT call this (it drives the tick from the main thread to
satisfy the pywinctl/macOS constraint)."""
if self.overlay_handler is None:
self.log.info("No window handler — core window tracking stays off.")
return
def _loop():
# pywinctl talks COM on Windows; a freshly-spawned thread must
# initialize COM or getActiveWindow() fails with "Invalid syntax"
# (0x80040E14). The Qt GUI gets this free on its main thread, but
# this core-owned tick thread (headless / H3) does not.
com_inited = False
if sys.platform == "win32":
try:
import pythoncom
pythoncom.CoInitialize()
com_inited = True
except Exception:
self.log.warning("COM init for window tracking failed", exc_info=True)
try:
while not self._tick_stop.is_set():
try:
self.tick_window_tracking()
except Exception:
self.log.exception("Window-tracking tick failed")
self._tick_stop.wait(interval_s)
finally:
if com_inited:
try:
import pythoncom
pythoncom.CoUninitialize()
except Exception:
pass
# Guard the check-and-create so two callers can't start two threads.
with self._tick_lock:
if self._tick_thread is not None:
return
self._tick_stop.clear()
self._tick_thread = threading.Thread(
target=_loop, name="poly-window-tick", daemon=True)
self._tick_thread.start()
self.log.info("Core-owned window tracking started.")
def shutdown(self):
"""Orderly stop: persist MRU, stop listeners/threads. Never raises.
Persist the keyboard's MRU recents on a clean shutdown (the firmware
only writes if they changed). USB suspend covers the sleep case; this
covers a clean quit/logout where USB suspend may not fire. Run it
synchronously (short bounded wait) BEFORE stopping the worker, but
never let it block shutdown."""
self._tick_stop.set()
if self._tick_thread is not None:
self._tick_thread.join(timeout=1)
self._tick_thread = None
try:
self.worker.run_sync("save_mru", lambda c: self.keeb.save_mru(), timeout=2)
except Exception as e: # never let a save attempt break shutdown
self.log.debug("MRU save request failed: %s: %s", type(e).__name__, e)
if self._sleep_listener is not None:
self._sleep_listener.close()
self.telemetry.stop()
self.worker.stop()
self.browser_url_source.close()
if self.overlay_handler is not None:
self.overlay_handler.close()
def save_mru(self):
"""Best-effort request to persist the keyboard's emoji/language MRU.
Safe to call when disconnected — the HID layer just reports failure
and we swallow any error so shutdown/sleep is never blocked.
Submitted as a normal worker job (device I/O stays on the worker)."""
try:
if self.keeb:
self.worker.submit("save_mru", lambda c: self.keeb.save_mru())
except Exception as e: # never let a save attempt break shutdown/sleep
self.log.debug("MRU save request failed: %s: %s", type(e).__name__, e)
# ------------------------------------------------------------------
# Overlay mapping / active-window handler
# ------------------------------------------------------------------
def load_overlay_mapping(self, path):
import yaml
try:
with open(path, encoding="utf-8") as f:
# safe_load: the mapping file is plain title→overlay-name data;
# never instantiate arbitrary Python objects from it.
loaded = yaml.safe_load(f) or {}
if not isinstance(loaded, dict):
self.log.warning("Overlay mapping %s is not a mapping; ignoring.", path)
loaded = {}
self.mapping = loaded
except (OSError, yaml.YAMLError) as e:
self.log.warning("Could not read overlay mapping %s: %s", path, e)
self.mapping = {}
def save_overlay_mapping(self, path):
import yaml
with open(path, "w", encoding="utf-8") as f:
f.write(yaml.dump(self.mapping))
def _create_overlay_handler(self):
try:
from polyhost.handler.active_window import OverlayHandler
# url_provider lets the matcher key overlays off the focused
# browser's website; None-safe (returns None for non-browsers / when
# no reporter is present, so matching is unchanged without it).
url_lookup = (self.browser_url_source.current_url
if self.browser_url_source.enabled else None)
self.overlay_handler = OverlayHandler(
self.mapping, url_provider=url_lookup,
enable_legacy_relay=bool(self.settings_get("dev_legacy_plaintext_relay")),
rpc_relay_enabled=bool(self.settings_get("window_report_network_enabled")))
except Exception as e:
# Headless / no display: pywinctl cannot load. Window-driven
# overlay switching stays off; explicit sends still work.
self.overlay_handler = None
self.log.warning("Window tracking unavailable (%s: %s) — "
"active-window overlay switching disabled.",
type(e).__name__, e)
def _on_browser_url_changed(self):
"""A real URL change arrived: nudge window tracking so an SPA route
change (which moves no window title) still swaps overlays."""
if self.overlay_handler is not None:
self.overlay_handler.invalidate_window_cache()
# ------------------------------------------------------------------
# Overlay jobs (HID worker)
# ------------------------------------------------------------------
def send_overlay_data(self, data):
"""Queue a (coalesced) overlay send for one or more template names."""
files = []
if isinstance(data, str):
files.append(get_overlay_path(data))
else:
for overlay in data:
files.append(get_overlay_path(overlay))
if len(files) == 0:
return False
# Device I/O runs on the worker; coalesce_key="overlay" supersedes a
# pending/in-flight send so rapid alt-tabbing doesn't replay transfers.
# A client renders "thinking" off this event and clears it on the
# "overlay" completion event.
self.emit("overlay_activity", {"state": "thinking"})
self.worker.submit("overlay", lambda cancel: self._overlay_send_job(files, cancel),
coalesce_key="overlay",
on_done=lambda name, result: self.emit(name, result))
return True
def tick_window_tracking(self, update_cycle_msec=UPDATE_CYCLE_MSEC,
new_window_accept_msec=NEW_WINDOW_ACCEPT_TIME_MSEC):
"""One active-window poll: switch overlays for the focused app.
NO direct device I/O — pushes go through the worker. The active-window
query (pywinctl) runs on the CALLER's thread: the GUI calls this from
its main-thread QTimer (pywinctl/macOS must stay main-thread, per the
worker refactor); headless mode calls it from the core's own tick
thread (H3). When there is no window handler (no display) this is a
no-op — explicit overlay sends via the API still work."""
handler = self.overlay_handler
if handler is None:
return
# safe_mode (newer firmware, user chose restricted): connected but no
# operational overlay/OS traffic — only firmware-update + debugging.
if self.connected and not self.safe_mode:
data, cmd = handler.handle_active_window(update_cycle_msec, new_window_accept_msec)
if cmd in (OverlayCommand.DISABLE, OverlayCommand.ENABLE):
self.submit_overlay_cmd(cmd)
if data and cmd == OverlayCommand.OFF_ON:
self.send_overlay_data(data)
self._track_active_os(handler)
elif self.poly_settings.get("dev_run_window_detection_if_not_connected_to_poly_kybd"):
handler.handle_active_window(update_cycle_msec, new_window_accept_msec)
def _track_active_os(self, handler):
"""Keep the keyboard's OS in sync with the machine currently driving the
display: the forwarder's OS while a remote-forwarded window is active, else
the local OS. This is what makes the OS feature follow a forwarded session
(the keyboard reflects whichever computer you're working on), and revert to
the local OS when local window tracking takes back over. Deduped via
``_push_os`` so set_os only fires on an actual change."""
from polyhost.input.unicode_input import get_host_os
from polyhost.device.command_ids import OsType
forwarded = None
rh = getattr(handler, "remote_handler", None)
if rh is not None and handler.is_remote_mapping_entry():
forwarded = getattr(rh, "forwarded_os", None)
# A forwarded UNKNOWN(0)/None means the forwarder didn't report an OS — keep
# the local OS rather than blanking the keyboard back to auto/unknown.
desired = get_host_os()
if isinstance(forwarded, int) and forwarded:
try:
desired = OsType(forwarded)
except ValueError:
pass # unknown wire value — fall back to the local OS
self._push_os(desired)
def _push_os(self, os):
"""Submit a host-auto OS push to the keyboard, deduped against the last one.
Accepts an OsType (or int); a no-op when it matches what was last pushed.
set_os self-gates on protocol v7+, so this is harmless on older firmware."""
from polyhost.device.command_ids import OsType as _OsType
value = os.value if isinstance(os, _OsType) else int(os)
if value == self._last_pushed_os:
return
self._last_pushed_os = value
self.log.info("Pushing OS %s to keyboard.", _OsType(value))
self.worker.submit("set_os", lambda c, v=value: self.keeb.set_os(v))
def report_window(self, handle, name, title, os=None, url=None):
"""Inject an external active-window report into remote window tracking
(the ``window.report`` RPC / ``polyctl window report``).
``os`` (optional, an OsType value int) is the forwarder's host OS, stored
on the remote handler so the window-tracking tick can push it to the
keyboard while the forwarded window is the active overlay driver.
Mirrors what the cross-machine TCP relay does, but over the control
socket — a local client (or a future unified transport) can feed the
daemon's remote window matching without the bespoke TCP. No device I/O
and no worker needed: it just stores the report; the next
window-tracking tick applies it if a remote-mapping entry is active.
Returns the uniform ``(ok, payload)`` the RPC layer unwraps."""
handler = self.overlay_handler
if handler is None or getattr(handler, "remote_handler", None) is None:
return False, "window tracking unavailable"
handler.remote_handler.report_window(handle, name, title, os=os)
return True, {"reported": True}
def submit_overlay_cmd(self, cmd):
"""Queue an enable/disable of overlays (coalesces with sends)."""
self.worker.submit("overlay", lambda c, cmd=cmd: self._overlay_cmd_job(cmd, c),
coalesce_key="overlay")
def _overlay_send_job(self, files, cancel):
"""Worker-thread overlay send. Reset/enable that accompany a send stay
inside this job so ordering is preserved, and the cancel event is
forwarded through."""
try:
# MRU is the only overlay path. The old direct path never programmed
# overlay_map[] — it relied on the firmware's identity mapping, which
# now covers only the first NUM_OVERLAY_SLOTS (600) of the 810 flat
# (slot, variant) indices, so the high modifier variants would all
# fold onto pool slot 0. Mapping is therefore mandatory, and the
# per-device toggles that used to select between the two are gone.
for entry in self.device_mgr.all_entries:
if cancel.is_set():
return
entry.device.send_overlays_mru(files, entry.cache, cancel)
except Exception as e:
msg = f"Failed to send overlays '{files}': {e}"
self.log.warning(msg)
# Runs on the worker thread — clients marshal this to their own
# loop (the Qt client shows a tray warning).
self.emit("overlay_warning", msg)
self.keeb.set_idle(False)
# The send + enable just bridged data to the slave; mark the deaf window
# so the next reconnect probe skips it (avoids the EMPTY REPLY).
self._last_overlay_activity = time.monotonic()
def _overlay_cmd_job(self, cmd, cancel):
"""Worker-thread enable/disable of overlays on every device entry.
On a confirmed device-call FAILURE, re-arm the window handler (revert
its optimistic overlays_enabled) so the next poll re-issues the command
instead of the handler's redundant-command guard suppressing the retry —
a failed DISABLE must not leave the keyboard showing overlays while the
host believes they are off (and vice-versa). Success advances the state
the handler already set, so nothing to do."""
ok = True
for entry in self.device_mgr.all_entries:
if cancel.is_set():
return
# A device call can RAISE (e.g. an HID write on a disconnected
# handle), not just return (False, …). Treat a raise as a failed
# result and keep going so every entry is attempted and the re-arm
# below still runs — otherwise the exception would escape the job
# before note_overlay_state() and the retry would be suppressed.
try:
if cmd == OverlayCommand.DISABLE:
res = entry.device.disable_overlays()
elif cmd == OverlayCommand.ENABLE:
res = entry.device.enable_overlays()
else:
continue
if not (res is None or res[0]):
ok = False
except Exception as e:
self.log.warning("Overlay %s failed: %s", cmd, e)
ok = False
if not ok and self.overlay_handler is not None:
# Revert to the pre-command state (failed DISABLE -> "enabled",
# failed ENABLE -> "disabled") so the next tick retries.
self.overlay_handler.note_overlay_state(cmd == OverlayCommand.DISABLE)
# enable/disable force-syncs state to the slave too — same deaf window.
self._last_overlay_activity = time.monotonic()
# ------------------------------------------------------------------
# Worker periodics: reconnect probe, console/serial reads, brightness
# ------------------------------------------------------------------
def _reconnect_periodic(self, cancel):
"""Worker periodic (1 s): probe the device, publish the snapshot to
observers. Skipped automatically while suspended."""
snapshot = self._reconnect_probe(cancel)
if snapshot is not None:
# Headless: no GUI calls apply_reconnect, so the core applies its
# own snapshot (settles state + runs post-connect, emits
# status_changed). The Qt client applies it itself and leaves the
# flag False, so this never double-applies.
if self.apply_reconnect_in_core:
try:
self.apply_reconnect(snapshot)
except Exception:
# Never let an apply failure swallow the reconnect event —
# subscribers (e.g. ControlServer's fan-out to polyctl
# watch) must still see it.
self.log.exception("apply_reconnect failed in core periodic")
self.emit("reconnect", snapshot)
def _reconnect_probe(self, cancel):
"""Runs on the WORKER thread. Performs all device I/O for a reconnect
and returns a plain dict snapshot (or None to publish nothing) — no
UI access.
Only re-queries version/lang info when the probed connectivity differs
from the last applied state (read atomically under the GIL)."""
# Skip the probe inside the post-send deaf window: while we still think
# we're connected and an overlay/MRU send just bridged to the slave, the
# GET_ID would get an EMPTY REPLY. Publishing nothing leaves state and
# the fail-streak untouched; the next cycle (window lapsed) probes for
# real, and a genuine disconnect is caught then since sends have stopped.
if (self.last_applied_connected
and time.monotonic() - self._last_overlay_activity
< OVERLAY_PROBE_COOLDOWN_S):
return None
connected_now = False
present_now = False
response = ""
if self.keeb.hid is not None:
# Flush replies that arrived after their command gave up waiting
# (the keyboard answers late while it syncs a large overlay
# transfer to the slave half) — otherwise they get misread as the
# replies to this probe's queries.
self.keeb.hid.drain_replies(timeout_ms=2)
if self.keeb.connect():
# connect() succeeding (GET_ID answered / interface re-opened)
# already proves a flashable device is present, even if the
# GET_LANG probe below fails on a busy keyboard.
present_now = True
connected_now, response = self.keeb.query_current_lang()
# Debounce: a busy keyboard misses probes without being disconnected.
publish, self._probe_fail_streak = decide_probe_publish(
connected_now, self.last_applied_connected, self._probe_fail_streak)
if not publish:
return None
snapshot = {
"connected_now": connected_now,
"device_present": present_now,
"lang": response,
"state_changed": connected_now != self.last_applied_connected,
# Popped on every successful probe: the firmware sets the fresh-boot
# marker on any reboot, including ones too fast for the host to see a
# disconnect (watchdog reset, firmware apply). Consuming it only on
# connectivity changes would leave a stale MRU cache. Not popped on a
# failed probe so the marker survives until a probe that gets applied.
"fresh_boot": self.keeb.pop_fresh_boot() if connected_now else False,
}
if not snapshot["state_changed"]:
return snapshot
if not connected_now:
# Going disconnected: do NOT query version/languages — stale late
# replies from the failed probe can make query_version_info
# "succeed" and fake a fresh connect (cache reset + full overlay
# resend) against a device that just failed to answer GET_LANG.
snapshot.update({
"version_ok": False,
"version_msg": "Could not read reply from PolyKybd",
"kb_version": None, "kb_proto": None, "kb_sw_version": None,
"name": None, "hw_version": None,
"lang_list": None, "current_lang": None,
})
return snapshot
version_ok, version_msg = self.keeb.query_version_info()
snapshot.update({
"version_ok": version_ok,
"version_msg": version_msg,
"kb_version": self.keeb.get_sw_version(),
"kb_proto": self.keeb.get_protocol_version(),
"kb_sw_version": self.keeb.get_sw_version_number(),
"name": self.keeb.get_name(),
"hw_version": self.keeb.get_hw_version(),
})
# Enumerate languages for the menu rebuild (apply consumes the list).
if version_ok or self.ignore_version:
enum_ok, _ = self.keeb.enumerate_lang()
snapshot["lang_list"] = self.keeb.get_lang_list() if enum_ok else None
snapshot["current_lang"] = self.keeb.get_current_lang() if enum_ok else None
else:
snapshot["lang_list"] = None
snapshot["current_lang"] = None
return snapshot
def apply_reconnect(self, snapshot):
"""Apply a probe snapshot: the OPERATIONAL half of the reconnect.
Updates core connection state, runs the version/protocol decision
tree, and on a fresh compatible connect performs the post-connect
work (unicode mode push, cache resets, window-handler resend).
Returns an ``applied`` dict the calling client renders from (status
text/icon, menu rebuild, OS-language switch); the same data is
emitted as a ``status_changed`` event for passive observers.
Thread-agnostic: no UI access; device work goes through worker jobs.
"""
if self.paused:
return None
connected_now = snapshot["connected_now"]
# Presence (= flashable) comes from the probe's connect()/GET_ID, not
# from the GET_LANG result: a keyboard that answers GET_ID but misses
# the language probe (busy syncing the slave half) and one that fails
# the protocol/version check below must both keep firmware actions
# available. Fall back to connected_now for snapshots without the key.
self.device_present = snapshot.get("device_present", connected_now)
applied = {
"state_changed": snapshot["state_changed"],
"connected_now": connected_now,
"lang": snapshot["lang"],
"decision": None,
"do_overlay_reset": False,
"fresh_boot": False,
}
if snapshot["state_changed"]:
# Forget a remembered newer-firmware choice if the device's protocol
# changed (e.g. after a firmware flash) so the user is asked again for
# the new firmware rather than silently reusing the old decision.
if (self._newer_fw_policy_proto is not None
and snapshot.get("kb_proto") != self._newer_fw_policy_proto):
self._newer_fw_policy = None
self._newer_fw_policy_proto = None
decision = decide_reconnect_apply(
snapshot, __protocol__, __version__, self.ignore_version,
min_supported=MIN_SUPPORTED_PROTOCOL,
newer_fw_policy=self._newer_fw_policy)
applied["decision"] = decision
self.safe_mode = decision.get("safe_mode", False)
# Mirror the original warning logs.
if not snapshot["version_ok"] and self.ignore_version:
self.log.warning(
"FW version string could not be parsed (%s) — continuing via --ignore-version",
snapshot["version_msg"])
if "version_warning" in decision:
expected, kb_version = decision["version_warning"]
self.log.warning("Warning! Version mismatch, expected '%s', got '%s'.",
expected, kb_version)
if "ignore_bypass_msg" in decision:
self.log.warning("Version/protocol mismatch bypassed via --ignore-version: %s",
decision["ignore_bypass_msg"])
self.connected = decision["connected"]
# Census counters: a fresh connect vs losing one already-connected
# keyboard. The flap count is the one number that would actually
# tell us a tester's link is unhealthy without asking them.
if connected_now:
self.telemetry.note("connects")
else:
self.telemetry.note("reconnect_flaps")
if snapshot["version_ok"] or self.ignore_version:
self.kb_sw_version = snapshot["kb_sw_version"]
if decision["do_post_connect"]:
if connected_now and self.poly_settings.get("unicode_send_composition_mode"):
from polyhost.input.unicode_input import get_input_method
mode = get_input_method()
self.log.info("Setting unicode mode to str %s", mode)
# set_unicode_mode is device I/O -> worker job.
self.worker.submit("set_unicode_mode",
lambda c, m=mode: self.keeb.set_unicode_mode(m))
if connected_now:
# Push the host OS (independent of the unicode mode). The keyboard
# applies it only in auto mode (a manual pin / Android wins), and
# set_os self-gates on protocol v7+, so this is a no-op on older
# firmware. Re-asserted on every connect — host wins when present.
# Force the push (last_pushed reset) so a reconnect always re-syncs.
from polyhost.input.unicode_input import get_host_os
self._last_pushed_os = None
self._push_os(get_host_os())
self.device_mgr.reset_all_caches()
if self.overlay_handler is not None:
self.overlay_handler.force_resend()
self.needs_overlay_reset = True
self.log.info("Connected: active window resend queued.")
# Re-assert the host's brightness mode on the freshly-connected
# keyboard (its auto mode is RAM-only and defaults off on boot):
# engage daylight-auto + push the current value, or send AUTO_OFF
# so it uses its stored manual brightness. Queued on the worker.
self.refresh_daylight_brightness()
# Auto-flash the bundled font pack if the keyboard's is missing
# or older (queued on the worker; self-terminating — see below).
# Gated on the font-pack capability (v6+ reports bundle versions in
# GET_ID): older firmware can't tell us what it has, so we must not
# blindly mass-flash it now that we connect across protocols.
if self.keeb.supports("fontpack"):
self._maybe_auto_flash_fontpack()
# The applying client owns the applied-connection state the worker reads.
self.last_applied_connected = self.connected
if not connected_now:
self.log.warning("Reconnect failed: '%s'",
snapshot["lang"] if snapshot["lang"] else "NO RESPONSE")
if self.connected:
if snapshot["state_changed"] and self.needs_overlay_reset:
self.needs_overlay_reset = False
applied["do_overlay_reset"] = True
# We just reset our OWN MRU cache (reset_all_caches above) to
# empty, but the keyboard kept whatever pool it had — a fresh
# host process (or daemon restart) connects to a keyboard that
# never rebooted, so its overlay pool is still populated. Unless
# we clear it, the empty host cache and the stale keyboard pool
# are desynced and a later cache-hit ("0 upload") send maps
# display positions onto slots the new session never wrote —
# icons from a previous app/session bleed through.
#
# The GUI consumes do_overlay_reset and calls core.reset_overlays()
# itself. Headless (apply_reconnect_in_core) ignores the returned
# `applied`, so nothing cleared the keyboard there. Do it now —
# we're on the worker thread, so call the device directly
# (reset_overlays() would worker.run_sync and deadlock the worker
# on itself).
if self.apply_reconnect_in_core:
try:
self.keeb.reset_overlays_and_usage()
self.log.info("Connected: keyboard overlay state cleared.")
except Exception as e:
self.log.warning("Connect-time overlay reset failed: %s", e)
# Independent of state_changed: a fast reboot (no observed
# disconnect) still must invalidate the host-side MRU cache.
if snapshot.get("fresh_boot"):
self.device_mgr.reset_all_caches()
self.log.info("Firmware restart detected — overlay MRU cache reset.")
applied["fresh_boot"] = True
self.emit("status_changed", {
"connected": self.connected,
"device_present": self.device_present,
"paused": self.paused,
"state_changed": snapshot["state_changed"],
"text": (applied["decision"] or {}).get("text"),
"icon": (applied["decision"] or {}).get("icon"),
"lang": snapshot["lang"],
# Carry the device protocol + per-feature capabilities so a --connect
# client can gate feature menus the same way the in-process app does
# (the steady-state event, unlike status.get, otherwise omits them).
"protocol": self.keeb.get_protocol_version(),
"capabilities": self._reported_capabilities(),
# Newer-firmware safe mode + whether the user still needs to be
# prompted (drives the client's newer-firmware dialog off the status
# seam — no separate event needed, and a late-attaching client sees it).
"safe_mode": self.safe_mode,
"newer_fw_pending": bool(
(applied["decision"] or {}).get("newer_fw_pending")),
})
return applied
def _reported_capabilities(self):
"""Per-feature capabilities as reported to clients. In safe mode every
gated feature reads False so the UI (feature submenus, glyph-reset button,
`polyctl status`) disables them with no extra mode-specific code."""
caps = self.keeb.capabilities()
if self.safe_mode:
return {k: False for k in caps}
return caps
def _console_periodic(self, cancel):
"""Worker periodic (250 ms): read serial + console; publish."""
kb_serial = self.keeb.read_serial()
kb_log = self.keeb.get_console_output()
if kb_serial or kb_log:
self.emit("console", (kb_serial, kb_log))
# HID SET_BRIGHTNESS flag bits — mirror firmware base/com.h (protocol >= 5).
# On older firmware the flags byte is ignored (plain persisted set), so we
# only send flags when the device advertises support.
_BR_FLAG_VOLATILE = 1 << 0 # daylight value: applied only in auto mode, never persisted
_BR_FLAG_AUTO_ON = 1 << 1 # engage host-driven (auto) brightness
_BR_FLAG_AUTO_OFF = 1 << 2 # leave auto mode, revert to the keyboard's stored manual level
_BRIGHTNESS_FLAGS_PROTOCOL = 5
def _brightness_flags_supported(self):
return (self.keeb.get_protocol_version() or 0) >= self._BRIGHTNESS_FLAGS_PROTOCOL
def _compute_daylight_value(self):
"""Map the current daylight irradiance to a device value (2..50),
applying the perceptual gamma. The keycap OLEDs are driven near the
bottom of their contrast range (firmware caps at 49/50 for current/
burn-in), where perceived brightness ~ luminance^(1/3), so a linear
value feels uneven; gamma>1 evens out the perceived steps (1.0 = the
old linear behaviour). Endpoints (0->2, 1->50) are preserved."""
min_val = self.poly_settings.get("irradiance_min")
max_val = self.poly_settings.get("irradiance_max")
prescaler = self.poly_settings.get("irradiance_prescaler")
brightness = self.sunlight.get_brightness_now(min_val, max_val, prescaler)
gamma = self.poly_settings.get("brightness_gamma")
if gamma and gamma > 0:
brightness = brightness ** gamma
return 2 + brightness * 48
def _brightness_periodic(self, cancel):
"""Worker periodic (10 min): daylight-dependent brightness incl. the
network lookups — kept entirely off any client thread. Sends a VOLATILE
update only (never AUTO_ON): if the user has taken manual control on the
keyboard the firmware ignores it, so a background tick can't override a
deliberate choice. Engaging auto is a deliberate act (see _engage)."""
# Skip while disconnected: there is no keyboard to set, and the compute
# step does live network lookups (irradiance/location) that would run
# for nothing on the 10-min tick.
if not self.connected:
return
if self.poly_settings.get("brightness_set_daylight_dependent"):
val = self._compute_daylight_value()
flags = self._BR_FLAG_VOLATILE if self._brightness_flags_supported() else 0
self.keeb.set_brightness(val, flags)
def _engage_brightness(self, cancel):
"""Deliberate (re-)assert of the host's brightness mode — runs on a
settings change or on connect. Daylight on -> engage auto mode and push
the current value (VOLATILE|AUTO_ON); daylight off -> tell the keyboard
to leave auto mode and fall back to its stored manual brightness
(AUTO_OFF). Both clear any prior keyboard manual override, which is the
intended 'the host re-takes control' semantics."""
supported = self._brightness_flags_supported()
if self.poly_settings.get("brightness_set_daylight_dependent"):
val = self._compute_daylight_value()
flags = (self._BR_FLAG_VOLATILE | self._BR_FLAG_AUTO_ON) if supported else 0
self.keeb.set_brightness(val, flags)
elif supported:
# Daylight disabled: leave auto mode; the keyboard restores its own
# persisted manual brightness (level byte ignored on AUTO_OFF). On
# pre-v5 firmware there is no auto mode, so there is nothing to do.
self.keeb.set_brightness(0, self._BR_FLAG_AUTO_OFF)
# Settings whose change should immediately recompute + retransmit the
# daylight brightness rather than waiting for the next 10-min periodic.
_BRIGHTNESS_SETTING_KEYS = frozenset({
"brightness_set_daylight_dependent",
"irradiance_min", "irradiance_max", "irradiance_prescaler",
"brightness_gamma",
"brightness_allow_online_irradiance_request",
"brightness_allow_online_location_lookup",
})
def refresh_daylight_brightness(self):
"""(Re-)assert the host brightness mode on the device now, instead of
waiting for the next 10-min periodic — used on a settings change and on
connect. Runs on the worker so it never blocks the caller; coalesces so
a burst of setting changes results in a single push."""
# Keep the Sunlight lookup permissions in sync with the live settings,
# so toggling the online-lookup options takes effect immediately too.
self.sunlight.allow_online_lookup(
bool(self.poly_settings.get("brightness_allow_online_irradiance_request")))
self.sunlight.allow_location_lookup(
bool(self.poly_settings.get("brightness_allow_online_location_lookup")))
self.worker.submit("brightness_now", self._engage_brightness,
coalesce_key="brightness_now")
# (ok, payload) like every other command-API method, so the control
# socket and the in-process caller see the same shape. It is a submit,
# not a run_sync, so "queued" is all there is to report.
return True, "queued"
# ------------------------------------------------------------------
# Command API — the surface clients (CLI / RPC / GUI) drive (H2).
#
# Each device-touching call goes through the worker: short
# request/response commands use run_sync (bounded block, raises while
# suspended); long/coalescing ones (overlay send, command scripts) use
# submit. Return shapes are plain JSON-serializable values/dicts so the
# in-process observer and the socket transport are identical.
# ------------------------------------------------------------------