forked from funkypitt/Tinta4Plus-Universal
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTinta4Plus.py
More file actions
executable file
·1952 lines (1620 loc) · 81 KB
/
Copy pathTinta4Plus.py
File metadata and controls
executable file
·1952 lines (1620 loc) · 81 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
#!/usr/bin/env python3
"""
Copyright (c) 2025 Jon Cox (joncox123). All rights reserved.
WARNING: This software is provided "AS IS", without any warranty of any kind. It may contain bugs or other defects
that result in data loss, corruption, hardware damage or other issues. Use at your own risk.
It may temporarily or permanently render your hardware inoperable.
It may corrupt or damage the Embedded Controller or eInk T-CON controller in your laptop.
The author is not responsible for any damage, data loss or lost productivity caused by use of this software.
By downloading and using this software you agree to these terms and acknowledge the risks involved.
"""
"""
ThinkBook Plus Gen 4 IRU E-Ink Control GUI (tkinter version)
Unprivileged GUI that communicates with privileged helper daemon
No root/sudo required for this GUI
Communicates via Unix socket with helper daemon
"""
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox
import subprocess
import sys
import os
import time
import threading
import logging
import random
import webbrowser
import json
from datetime import datetime
try:
import sv_ttk
HAS_SV_TTK = True
except ImportError:
HAS_SV_TTK = False
from HelperClient import HelperClient
from DisplayManager import DisplayManager
from ThemeManager import ThemeManager
from ResumeCheck import ResumeCheck
class FloatingRefreshButton:
"""Floating refresh button window that stays on top"""
def __init__(self, parent, on_refresh_callback, logger):
"""Initialize the floating refresh button
Args:
parent: Parent tkinter window
on_refresh_callback: Function to call when button is pressed
logger: Logger instance
"""
self.parent = parent
self.on_refresh_callback = on_refresh_callback
self.logger = logger
# Drag state
self._drag_start_x = 0
self._drag_start_y = 0
self._is_dragging = False
# Create a new top-level window
self.window = tk.Toplevel(parent)
self.window.title("") # Empty title
# Remove window decorations (titlebar, close button, etc.)
self.window.overrideredirect(True)
# Set size to 75x75
self.window.geometry("75x75")
# Make window always stay on top
self.window.attributes('-topmost', True)
# Make window semi-transparent (0.5 = 50% opacity)
self.window.attributes('-alpha', 0.5)
# Position window on left side, halfway down
# Get screen height to calculate vertical center
screen_height = self.window.winfo_screenheight()
y_position = (screen_height // 2) - 37 # Center the 75px button
x_position = 0 # Left edge of screen
self.window.geometry(f"75x75+{x_position}+{y_position}")
# Set window background to light blue
self.window.config(bg='lightblue')
# Create the refresh button that fills the entire window
# Using a unicode circular arrow character as refresh icon
# Note: highlightthickness=0 removes focus border, bd=0 removes button border
self.button = tk.Button(
self.window,
text="⟳", # Circular arrow refresh symbol
font=('TkDefaultFont', 54), # Scaled down from 72 to fit 75x75
command=self._on_click,
relief=tk.FLAT,
bd=0,
bg='lightblue',
activebackground='skyblue',
highlightthickness=0
)
self.button.pack(fill=tk.BOTH, expand=True)
# Bind hover effects
self.button.bind("<Enter>", self._on_hover_enter)
self.button.bind("<Leave>", self._on_hover_leave)
# Bind drag events
self.button.bind("<ButtonPress-1>", self._on_drag_start)
self.button.bind("<B1-Motion>", self._on_drag_motion)
self.button.bind("<ButtonRelease-1>", self._on_drag_release)
self.logger.info("Floating refresh button created")
def _on_click(self):
"""Handle button click (only if not dragging)"""
if not self._is_dragging:
self.logger.info("Floating refresh button clicked")
if self.on_refresh_callback:
self.on_refresh_callback()
def _on_drag_start(self, event):
"""Handle start of drag operation"""
self._drag_start_x = event.x
self._drag_start_y = event.y
self._is_dragging = False
def _on_drag_motion(self, event):
"""Handle drag motion"""
# Calculate distance moved
dx = event.x - self._drag_start_x
dy = event.y - self._drag_start_y
# If moved more than a few pixels, consider it a drag (not a click)
if abs(dx) > 3 or abs(dy) > 3:
self._is_dragging = True
# Get current window position
x = self.window.winfo_x() + dx
y = self.window.winfo_y() + dy
# Move the window
self.window.geometry(f"+{x}+{y}")
def _on_drag_release(self, _event):
"""Handle end of drag operation"""
# Reset drag flag after a short delay to prevent click from firing
self.window.after(100, self._reset_drag_flag)
def _reset_drag_flag(self):
"""Reset the dragging flag"""
self._is_dragging = False
def _on_hover_enter(self, event):
"""Handle mouse hover enter"""
self.button.config(bg='skyblue')
self.window.config(bg='skyblue')
def _on_hover_leave(self, event):
"""Handle mouse hover leave"""
self.button.config(bg='lightblue')
self.window.config(bg='lightblue')
def destroy(self):
"""Destroy the floating button window"""
self.logger.info("Destroying floating refresh button")
if self.window:
self.window.destroy()
self.window = None
class EInkControlGUI:
# Version
VERSION = "0.1.0 alpha"
# Configuration
SOCKET_PATH = '/tmp/tinta4plusu.sock'
KEEPALIVE_INTERVAL = 5.0 # seconds (send keepalive every 5s, watchdog is 60s)
SOCKET_TIMEOUT = 10.0 # seconds
CONFIG_DIR = os.path.expanduser("~/.config/Tinta4PlusU")
SETTINGS_FILE = os.path.join(os.path.expanduser("~/.config/Tinta4PlusU"), "settings")
# Display names (ThinkBook Plus Gen 4 has eDP-1=OLED, eDP-2=E-Ink)
DISPLAY_OLED = "eDP-1"
DISPLAY_EINK = "eDP-2"
# E-Ink privacy images (one picked at random when disabling E-Ink)
# NOTE: must install feh and imv for this to work!
EINK_DISABLED_IMAGES = [
"eink-disable.jpg",
]
# XFCE theme names
THEME_HIGH_CONTRAST = "HighContrast"
THEME_ADWAITA_DARK = "Adwaita-dark"
"""Main GUI application using tkinter"""
def __init__(self, root, HELPER_SCRIPT, logger, autostart=False):
self.HELPER_SCRIPT = HELPER_SCRIPT
self.logger = logger
self.root = root
self.root.title("ThinkBook E-Ink Control")
# Fit the window to available screen height so the title bar and
# close button are always reachable, even on GNOME with its top
# bar eating vertical space on scaled displays.
screen_h = self.root.winfo_screenheight()
# Reserve generous space for GNOME top bar + window decorations
# so the title bar (close/minimize/maximize) is never clipped.
win_h = min(650, screen_h - 120)
self.root.geometry(f"600x{win_h}+50+50")
self.root.minsize(400, 400)
# Helper client
self.helper = HelperClient(logger)
self.keepalive_after_id = None
self._keepalive_thread = None
self._keepalive_stop = threading.Event()
self.helper_process = None
# Managers
self.display_mgr = DisplayManager(logger)
self.theme_mgr = ThemeManager(logger)
# Log detected session info
self.logger.info(f"Session: {self.display_mgr.session_type}, Desktop: {self.display_mgr.desktop_env}")
# Brightness timer for debouncing
self.brightness_timer = None
# Periodic refresh timer
self.refresh_timer = None
# Image viewer process for E-Ink privacy screen
self.eink_image_process = None
# Floating refresh button
self.floating_refresh_button = None
# Saved OLED scale to restore when switching back from eInk
self.saved_oled_scale = None
# Sleep inhibitor file descriptor (systemd-inhibit)
self._sleep_inhibit_fd = None
# Saved keyboard layout (restored after eInk toggle and resume)
self.saved_keyboard_layout = None
# Resume monitor thread
self._resume_monitor_thread = None
self._resume_monitor_stop = threading.Event()
# Load settings from file (or use defaults)
settings = self.load_settings()
# Display scaling (from settings)
self.display_scale = settings['display_scale']
# Build UI
self.build_ui()
# Apply loaded settings to UI controls after they're created
self.scale_var.set(self.display_scale)
self.scale_label.config(text=f"{self.display_scale:.2f}")
self.refresh_period_var.set(settings['refresh_period'])
self.refresh_period_label.config(text=str(settings['refresh_period']))
self.autoswitch_theme_var.set(settings['autoswitch_theme'])
self.flip_countdown = settings['flip_countdown']
self._countdown_active = False
# Set up window close handler
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
# Save the initial keyboard layout so we can restore it after resume
self.saved_keyboard_layout = self.display_mgr.get_keyboard_layout()
if self.saved_keyboard_layout:
self.logger.info(f"Saved initial keyboard layout: {self.saved_keyboard_layout}")
# Start monitoring for system resume (lid open / wake from suspend)
self._start_resume_monitor()
# Run startup display/input validation after a short delay
# (gives the display subsystem time to settle after login)
self.root.after(3000, self._run_startup_check)
if autostart:
# Autostart mode: don't launch helper immediately (avoids password prompt at login)
self.update_status("Click 'Connect to Helper' to start")
self.log_message("Autostart mode — helper not launched automatically")
self._check_secure_boot_local()
else:
# Normal launch: connect to helper after short delay
self.root.after(500, self.initialize_helper)
def load_settings(self):
"""Load settings from configuration file"""
# Default settings
defaults = {
'display_scale': 1.0,
'refresh_period': 0,
'autoswitch_theme': False,
'flip_countdown': 5
}
if not os.path.exists(self.SETTINGS_FILE):
self.logger.info(f"Settings file not found, using defaults")
return defaults
try:
with open(self.SETTINGS_FILE, 'r') as f:
settings = json.load(f)
self.logger.info(f"Loaded settings from {self.SETTINGS_FILE}")
# Merge with defaults to handle missing keys
for key in defaults:
if key not in settings:
settings[key] = defaults[key]
return settings
except Exception as e:
self.logger.error(f"Failed to load settings: {e}")
return defaults
def save_settings(self):
"""Save current settings to configuration file"""
try:
# Ensure config directory exists
os.makedirs(self.CONFIG_DIR, exist_ok=True)
# Gather current settings
settings = {
'display_scale': self.display_scale,
'refresh_period': self.refresh_period_var.get(),
'autoswitch_theme': self.autoswitch_theme_var.get(),
'flip_countdown': self.flip_countdown
}
# Write to file
with open(self.SETTINGS_FILE, 'w') as f:
json.dump(settings, f, indent=2)
self.logger.info(f"Saved settings to {self.SETTINGS_FILE}")
except Exception as e:
self.logger.error(f"Failed to save settings: {e}")
def _inhibit_sleep(self):
"""Acquire a sleep/suspend inhibitor via systemd-logind D-Bus.
This prevents the system from suspending while display switching is
in progress. The inhibitor is released by calling _uninhibit_sleep().
"""
if self._sleep_inhibit_fd is not None:
return # already held
try:
import dbus
bus = dbus.SystemBus()
proxy = bus.get_object('org.freedesktop.login1',
'/org/freedesktop/login1')
mgr = dbus.Interface(proxy, 'org.freedesktop.login1.Manager')
fd = mgr.Inhibit(
'sleep', # what
'Tinta4PlusU', # who
'Switching displays', # why
'block', # mode
)
# fd is a dbus.UnixFd; take ownership of the underlying file descriptor
self._sleep_inhibit_fd = fd.take()
self.logger.info("Acquired sleep inhibitor")
except Exception as e:
self.logger.warning(f"Could not acquire sleep inhibitor: {e}")
def _uninhibit_sleep(self):
"""Release the sleep/suspend inhibitor."""
if self._sleep_inhibit_fd is not None:
try:
os.close(self._sleep_inhibit_fd)
self.logger.info("Released sleep inhibitor")
except OSError as e:
self.logger.warning(f"Error releasing sleep inhibitor: {e}")
self._sleep_inhibit_fd = None
def build_ui(self):
"""Build the tkinter user interface"""
# Configure style
if HAS_SV_TTK:
sv_ttk.set_theme("dark")
else:
style = ttk.Style()
style.theme_use('clam')
# Main frame with padding
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# Configure grid weights
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame.columnconfigure(0, weight=1)
row = 0
# Status bar
self.status_var = tk.StringVar(value="Status: Initializing...")
status_label = ttk.Label(main_frame, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W)
status_label.grid(row=row, column=0, sticky=(tk.W, tk.E), pady=(0, 5))
row += 1
# Status row: Secure Boot indicator + Connect button
status_row_frame = ttk.Frame(main_frame)
status_row_frame.grid(row=row, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
self.secureboot_frame = tk.Frame(status_row_frame, bg='gray', relief=tk.RIDGE, bd=2)
self.secureboot_frame.pack(side=tk.LEFT)
self.secureboot_label = tk.Label(self.secureboot_frame, text="Secure Boot: Unknown",
bg='gray', fg='white', font=('TkDefaultFont', 9, 'bold'),
padx=10, pady=3)
self.secureboot_label.pack()
self.connect_btn = ttk.Button(status_row_frame, text="Connect to Helper",
command=self.initialize_helper)
self.connect_btn.pack(side=tk.RIGHT, padx=(10, 0))
row += 1
# === Display Control Section ===
display_frame = ttk.LabelFrame(main_frame, text="Display Control", padding="10")
display_frame.grid(row=row, column=0, sticky=(tk.W, tk.E), pady=5)
display_frame.columnconfigure(0, weight=1)
display_frame.columnconfigure(1, weight=1)
row += 1
# E-Ink toggle control
eink_toggle_frame = ttk.Frame(display_frame)
eink_toggle_frame.grid(row=0, column=0, columnspan=2, padx=5, pady=5, sticky=(tk.W, tk.E))
self.eink_enabled_var = tk.BooleanVar(value=False)
self.eink_toggle_btn = tk.Button(eink_toggle_frame, text="eInk Disabled",
bg="#FF8C00", fg="white",
font=('TkDefaultFont', 10, 'bold'),
relief=tk.RAISED, bd=3,
command=self.on_eink_toggled,
activebackground="#CC7000", # Darker orange
activeforeground="black",
padx=20, pady=10)
self.eink_toggle_btn.pack(expand=True, fill=tk.X)
# Bind hover effects for eInk toggle button
self.eink_toggle_btn.bind("<Enter>", lambda e: self._on_eink_btn_hover(e, True))
self.eink_toggle_btn.bind("<Leave>", lambda e: self._on_eink_btn_hover(e, False))
# Refresh button
self.btn_refresh = ttk.Button(display_frame, text="Refresh eInk (Clear Ghosts)",
command=self.on_refresh_full,
state='disabled')
self.btn_refresh.grid(row=1, column=0, columnspan=2, padx=5, pady=5, sticky=(tk.W, tk.E))
# Mode buttons (Dynamic and Reading)
mode_frame = ttk.Frame(display_frame)
mode_frame.grid(row=2, column=0, columnspan=2, padx=5, pady=5, sticky=(tk.W, tk.E))
mode_frame.columnconfigure(0, weight=1)
mode_frame.columnconfigure(1, weight=1)
self.btn_set_dynamic = ttk.Button(mode_frame, text="Set Dynamic",
command=self.on_set_dynamic,
state='disabled')
self.btn_set_dynamic.grid(row=0, column=0, padx=(0, 2), sticky=(tk.W, tk.E))
self.btn_set_reading = ttk.Button(mode_frame, text="Set Reading",
command=self.on_set_reading,
state='disabled')
self.btn_set_reading.grid(row=0, column=1, padx=(2, 0), sticky=(tk.W, tk.E))
# Refresh period slider
refresh_period_label = ttk.Label(display_frame, text="Refresh period (s):")
refresh_period_label.grid(row=3, column=0, sticky=tk.W, padx=5, pady=5)
refresh_period_container = ttk.Frame(display_frame)
refresh_period_container.grid(row=3, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
refresh_period_container.columnconfigure(0, weight=1)
self.refresh_period_var = tk.IntVar(value=0)
self.refresh_period_slider = ttk.Scale(refresh_period_container, from_=0, to=60,
orient=tk.HORIZONTAL,
variable=self.refresh_period_var,
command=self.on_refresh_period_changed)
self.refresh_period_slider.grid(row=0, column=0, sticky=(tk.W, tk.E))
self.refresh_period_label = ttk.Label(refresh_period_container, text="0")
self.refresh_period_label.grid(row=0, column=1, padx=(5, 0))
# Display scale slider
scale_label = ttk.Label(display_frame, text="Display Scale:")
scale_label.grid(row=4, column=0, sticky=tk.W, padx=5, pady=5)
scale_container = ttk.Frame(display_frame)
scale_container.grid(row=4, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
# Autoswitch theme checkbox
self.autoswitch_theme_var = tk.BooleanVar(value=False)
self.autoswitch_theme_checkbox = ttk.Checkbutton(
display_frame,
text="Autoswitch theme (HighContrast ↔ Adwaita-dark)",
variable=self.autoswitch_theme_var,
command=self.on_autoswitch_theme_changed
)
self.autoswitch_theme_checkbox.grid(row=5, column=0, columnspan=2, sticky=tk.W, padx=5, pady=5)
scale_container.columnconfigure(0, weight=1)
self.scale_var = tk.DoubleVar(value=1.75)
self.scale_slider = ttk.Scale(scale_container, from_=1.0, to=2.0,
orient=tk.HORIZONTAL,
variable=self.scale_var,
command=self.on_scale_changed)
self.scale_slider.grid(row=0, column=0, sticky=(tk.W, tk.E))
self.scale_label = ttk.Label(scale_container, text="1.75")
self.scale_label.grid(row=0, column=1, padx=(5, 0))
# === Frontlight Control Section ===
frontlight_frame = ttk.LabelFrame(main_frame, text="Frontlight Control", padding="10")
frontlight_frame.grid(row=row, column=0, sticky=(tk.W, tk.E), pady=5)
frontlight_frame.columnconfigure(1, weight=1)
row += 1
# Brightness slider (frontlight auto-enables with eInk)
ttk.Label(frontlight_frame, text="Brightness (0-8):").grid(row=0, column=0,
sticky=tk.W, padx=5, pady=5)
brightness_container = ttk.Frame(frontlight_frame)
brightness_container.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
brightness_container.columnconfigure(0, weight=1)
self.brightness_var = tk.IntVar(value=5)
self.brightness_scale = ttk.Scale(brightness_container, from_=0, to=8,
orient=tk.HORIZONTAL,
variable=self.brightness_var,
command=self.on_brightness_changed)
self.brightness_scale.grid(row=0, column=0, sticky=(tk.W, tk.E))
self.brightness_label = ttk.Label(brightness_container, text="5")
self.brightness_label.grid(row=0, column=1, padx=(5, 0))
# Warning label for Secure Boot (initially hidden)
self.secure_boot_warning = ttk.Label(frontlight_frame,
text="⚠ Secure Boot is enabled. Frontlight controls disabled.\nPlease disable Secure Boot in BIOS (Press ENTER during boot).",
foreground='red', font=('TkDefaultFont', 9, 'bold'))
# Don't grid it yet - will be shown if needed
# === Activity Log Section ===
log_frame = ttk.LabelFrame(main_frame, text="Activity Log", padding="10")
log_frame.grid(row=row, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
log_frame.columnconfigure(0, weight=1)
log_frame.rowconfigure(0, weight=1)
main_frame.rowconfigure(row, weight=1)
row += 1
# Scrolled text widget
log_kwargs = {'height': 12, 'wrap': tk.WORD, 'state': tk.DISABLED, 'font': ('Courier', 9)}
if HAS_SV_TTK:
log_kwargs.update({'bg': '#1c1c1c', 'fg': '#e0e0e0', 'insertbackground': '#e0e0e0'})
self.log_text = scrolledtext.ScrolledText(log_frame, **log_kwargs)
self.log_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# Configure text tags for colored output
if HAS_SV_TTK:
self.log_text.tag_config('success', foreground='#57c785')
self.log_text.tag_config('error', foreground='#ff6b6b')
self.log_text.tag_config('info', foreground='#6bb3ff')
else:
self.log_text.tag_config('success', foreground='green')
self.log_text.tag_config('error', foreground='red')
self.log_text.tag_config('info', foreground='blue')
# Version and Buy Me A Coffee button row
version_coffee_frame = ttk.Frame(main_frame)
version_coffee_frame.grid(row=row, column=0, sticky=(tk.W, tk.E), padx=10, pady=5)
version_coffee_frame.columnconfigure(0, weight=1) # Allow space to expand between elements
# Version label (left side)
version_label = ttk.Label(version_coffee_frame, text=f"Version {self.VERSION}",
font=('TkDefaultFont', 8))
version_label.grid(row=0, column=0, sticky=tk.W)
# Buy Me A Coffee button (right side)
self.coffee_btn = tk.Button(version_coffee_frame, text="Buy Me A Coffee",
bg="#FF8C00", fg="white",
font=('TkDefaultFont', 8),
relief=tk.RAISED, bd=2,
command=self.on_buy_coffee,
activebackground="#CC7000", # Darker orange
activeforeground="white",
padx=8, pady=4)
self.coffee_btn.grid(row=0, column=1, sticky=tk.E)
# Bind hover effects for coffee button
self.coffee_btn.bind("<Enter>", lambda e: self.coffee_btn.config(bg="#CC7000"))
self.coffee_btn.bind("<Leave>", lambda e: self.coffee_btn.config(bg="#FF8C00"))
# Keyboard shortcuts
self.root.bind_all('<Help>', lambda e: self.on_refresh_full() if self.eink_enabled_var.get() else None)
# Map brightness keys to frontlight control when in eInk mode
self.root.bind_all('<XF86MonBrightnessUp>', self._on_brightness_key_up)
self.root.bind_all('<XF86MonBrightnessDown>', self._on_brightness_key_down)
# Initial log message
self.log_message("Application started")
def log_message(self, message, level='info'):
"""Add a message to the log view and logger"""
timestamp = datetime.now().strftime("%H:%M:%S")
# Determine tag based on message content or level
if '✓' in message or 'success' in message.lower():
tag = 'success'
logger_level = 'info'
elif '✗' in message or 'error' in message.lower() or 'failed' in message.lower():
tag = 'error'
logger_level = 'error'
else:
tag = level
logger_level = level
log_line = f"[{timestamp}] {message}\n"
# Insert into text widget
self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, log_line, tag)
self.log_text.see(tk.END) # Auto-scroll
self.log_text.config(state=tk.DISABLED)
# Also log via logger
if logger_level == 'error':
self.logger.error(message)
elif logger_level == 'warning':
self.logger.warning(message)
else:
self.logger.info(message)
def update_status(self, message, error=False):
"""Update status bar"""
self.status_var.set(f"Status: {message}")
def show_error_dialog(self, message):
"""Log error to activity log (non-blocking)"""
self.log_message(f"ERROR: {message}", level='error')
def show_info_dialog(self, message):
"""Log info to activity log (non-blocking)"""
self.log_message(message)
def initialize_helper(self):
"""Initialize connection to helper daemon"""
self.connect_btn.config(state='disabled')
# First try to connect to existing helper
if os.path.exists(self.SOCKET_PATH):
try:
if self.helper.connect(self.SOCKET_PATH, timeout=self.SOCKET_TIMEOUT):
self.update_status("Connected to helper daemon")
self.log_message("Connected to existing helper daemon")
self.start_keepalive()
self.root.after(500, self.check_ec_status)
return
except Exception as e:
self.log_message(f"Failed to connect to existing socket: {e}")
# Remove stale socket
try:
os.remove(self.SOCKET_PATH)
except:
pass
# No existing helper, launch it
self.log_message("Helper daemon not found, launching...")
self.update_status("Launching helper daemon (password required)...")
# Launch helper via pkexec in background
threading.Thread(target=self._launch_helper_thread, daemon=True).start()
def _launch_helper_thread(self):
"""Launch helper daemon in background thread"""
try:
helper_path = self.HELPER_SCRIPT
if not os.path.exists(helper_path):
self.root.after(0, self._helper_launch_failed, "Helper not found: " + helper_path)
return
# If helper is a compiled binary, run it directly via pkexec
# If it's a .py script, invoke via python3
if helper_path.endswith('.py'):
cmd = ['pkexec', 'python3', helper_path]
else:
cmd = ['pkexec', helper_path]
self.helper_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Wait for helper to start (longer at login when polkit agent may not be ready)
time.sleep(2.0)
# Try to connect — up to 20 attempts (≈12s total after initial wait)
max_attempts = 20
for attempt in range(max_attempts):
# Check if pkexec process died (user cancelled password, etc.)
if self.helper_process.poll() is not None:
rc = self.helper_process.returncode
self.root.after(0, self._helper_launch_failed,
f"Helper process exited (code {rc}) — password cancelled or pkexec failed")
return
if os.path.exists(self.SOCKET_PATH):
if self.helper.connect(self.SOCKET_PATH, timeout=self.SOCKET_TIMEOUT):
self.root.after(0, self._helper_launch_success)
return
time.sleep(0.5)
self.root.after(0, self._helper_launch_failed,
"Helper started but socket not available after 12s")
except Exception as e:
self.root.after(0, self._helper_launch_failed, str(e))
def _helper_launch_success(self):
"""Called when helper successfully launched"""
self.update_status("Connected to helper daemon")
self.log_message("Helper daemon launched successfully")
self.connect_btn.config(state='disabled')
self.start_keepalive()
# Check EC status
self.root.after(500, self.check_ec_status)
def _helper_launch_failed(self, error):
"""Called when helper launch failed"""
self.update_status(f"Failed to launch helper: {error}", error=True)
self.log_message(f"ERROR: Failed to launch helper - {error}", level='error')
self.connect_btn.config(state='normal')
self.show_error_dialog(
f"Failed to launch helper daemon:\n\n{error}\n\n"
"Make sure you entered the correct password."
)
# Update Secure Boot indicator locally (since we can't ask the helper)
self._check_secure_boot_local()
def _check_secure_boot_local(self):
"""Check Secure Boot status locally via mokutil (no helper needed)"""
try:
result = subprocess.run(['mokutil', '--sb-state'],
capture_output=True, text=True, timeout=2)
output = result.stdout.strip()
if 'SecureBoot enabled' in output:
self.secureboot_label.config(text="Secure Boot: ON", bg='red')
self.secureboot_frame.config(bg='red')
else:
self.secureboot_label.config(text="Secure Boot: OFF", bg='green')
self.secureboot_frame.config(bg='green')
except Exception as e:
self.logger.warning(f"Could not check Secure Boot locally: {e}")
def start_keepalive(self):
"""Start periodic keepalive messages in a background thread.
Using a dedicated thread instead of root.after() ensures keepalives
continue even when the tkinter event loop is blocked by long-running
operations (display switching, etc.) or when the system resumes from
suspend.
"""
self.stop_keepalive()
self._keepalive_stop.clear()
self._keepalive_thread = threading.Thread(
target=self._keepalive_loop, daemon=True
)
self._keepalive_thread.start()
self.logger.info(f"Started keepalive thread ({self.KEEPALIVE_INTERVAL}s interval)")
def stop_keepalive(self):
"""Stop the keepalive thread."""
self._keepalive_stop.set()
if self._keepalive_thread and self._keepalive_thread.is_alive():
self._keepalive_thread.join(timeout=3)
self._keepalive_thread = None
def _keepalive_loop(self):
"""Background thread that sends keepalives at regular intervals."""
while not self._keepalive_stop.wait(self.KEEPALIVE_INTERVAL):
if not self.helper.is_connected():
self.root.after(0, self._on_keepalive_lost,
"Helper disconnected")
return
try:
response = self.helper.send_command('keepalive')
if not response or not response.get('success'):
self.root.after(0, self._on_keepalive_lost,
"Keepalive failed")
return
# Process any hotkey notifications on the main thread
notifs = response.get('notifications', [])
if notifs:
self.root.after(0, self._process_notifications, notifs)
except Exception as e:
self.logger.error(f"Keepalive error: {e}")
self.root.after(0, self._on_keepalive_lost,
f"Lost connection to helper - {e}")
return
def _on_keepalive_lost(self, reason):
"""Called on the main thread when keepalive detects a disconnect."""
self.update_status("Helper disconnected - attempting restart...", error=True)
self.log_message(f"ERROR: {reason}", level='error')
self.attempt_helper_restart()
def _process_notifications(self, notifs):
"""Process hotkey notifications on the main thread."""
for notif in notifs:
self._handle_hotkey_notification(notif)
def _handle_hotkey_notification(self, notif):
"""Process a hotkey notification received from the helper daemon."""
ntype = notif.get('type')
if ntype == 'brightness':
level = notif.get('level')
if level is not None:
self.brightness_var.set(level)
self.brightness_label.config(text=str(level))
self.log_message(f"Hotkey: brightness set to {level}")
elif ntype == 'refresh':
self.log_message("Hotkey: eInk refresh performed")
def attempt_helper_restart(self):
"""Attempt to restart the helper daemon"""
# Stop keepalive thread
self.stop_keepalive()
self.log_message("Attempting to restart helper daemon...")
# Try to connect to existing socket first
if os.path.exists(self.SOCKET_PATH):
try:
if self.helper.connect(self.SOCKET_PATH, timeout=self.SOCKET_TIMEOUT):
self.log_message("✓ Reconnected to existing helper")
self.update_status("Reconnected to helper daemon")
self.start_keepalive()
# Re-check EC status and sync frontlight state after reconnect
self.root.after(500, self.check_ec_status)
return
except:
pass
# Need to launch new helper
self.log_message("Launching new helper daemon (password may be required)...")
self.update_status("Launching helper daemon...")
threading.Thread(target=self._launch_helper_thread, daemon=True).start()
def check_ec_status(self):
"""Check EC access status and disable frontlight controls if Secure Boot enabled"""
try:
response = self.helper.send_command('get-ec-status')
if response and response.get('success'):
ec_status = response.get('ec_status', {})
# Update Secure Boot status indicator
if ec_status.get('secure_boot_enabled'):
self.secureboot_label.config(text="Secure Boot: ON", bg='red')
self.secureboot_frame.config(bg='red')
else:
self.secureboot_label.config(text="Secure Boot: OFF", bg='green')
self.secureboot_frame.config(bg='green')
if ec_status.get('secure_boot_enabled') or not ec_status.get('available'):
# Secure Boot enabled or EC not available - disable frontlight controls
error_msg = ec_status.get('error_message', 'EC access not available')
self.log_message(f"⚠ {error_msg}", level='error')
# Show warning label
self.secure_boot_warning.grid(row=1, column=0, columnspan=2,
sticky=(tk.W, tk.E), padx=5, pady=10)
# Disable brightness slider (frontlight checkbox is already always disabled)
self.brightness_scale.config(state='disabled')
# Show dialog
if ec_status.get('secure_boot_enabled'):
messagebox.showwarning(
"Secure Boot Enabled",
"Secure Boot is currently enabled in your BIOS.\n\n"
"Frontlight controls require direct hardware access which is blocked by Secure Boot.\n\n"
"To enable frontlight controls:\n"
"1. Reboot your computer\n"
"2. Press ENTER (or F2) during boot to enter BIOS\n"
"3. Navigate to Security → Secure Boot\n"
"4. Set Secure Boot to 'Disabled'\n"
"5. Save and exit (F10)\n\n"
"Note: E-Ink display controls will continue to work normally."
)
else:
self.log_message(f"EC access not available: {error_msg}", level='error')
else:
self.log_message("EC access verified - frontlight controls enabled")
# Sync GUI with actual EC state
self.sync_frontlight_state()
except Exception as e:
self.logger.error(f"Failed to check EC status: {e}")
self.log_message(f"Warning: Could not verify EC status: {e}", level='error')
def sync_frontlight_state(self):
"""Query EC and update GUI to match actual frontlight state"""
try:
response = self.helper.send_command('get-frontlight-state')
if response and response.get('success'):
brightness = response.get('brightness_level')
if brightness is not None:
self.brightness_var.set(brightness)
self.brightness_label.config(text=str(brightness))
self.log_message(f"Synced brightness level: {brightness}")
except Exception as e:
self.logger.warning(f"Failed to sync frontlight state: {e}")
self.log_message(f"Warning: Could not sync frontlight state from EC", level='error')
def execute_helper_command(self, command, **params):
"""Execute a command via helper and handle response"""
if not self.helper.is_connected():
messagebox.showerror("Error", "Not connected to helper daemon.\n\nPlease click 'Connect to Helper' first.")
return None
try:
response = self.helper.send_command(command, **params)
if response and response.get('success'):
message = response.get('message', 'Command completed')
self.log_message(f"✓ {message}")
# Log readback value if present
if 'readback' in response:
self.log_message(f" Readback value: {response['readback']}")
return response
else:
error = response.get('error', 'Unknown error') if response else 'No response'
self.log_message(f"✗ Command failed: {error}", level='error')
self.show_error_dialog(f"Command failed:\n\n{error}")
return None
except Exception as e:
self.log_message(f"✗ Command error: {e}", level='error')
self.show_error_dialog(f"Command error:\n\n{e}")
return None
# === Event Handlers ===
def on_eink_toggled(self, skip_countdown=False):
"""Handle E-Ink display toggle with flip countdown."""
if self._countdown_active:
self.log_message("Switch already in progress...", level='warning')
return
countdown = self.flip_countdown if not skip_countdown else 0
if countdown > 0:
direction = "eInk" if not self.eink_enabled_var.get() else "OLED"
self.log_message(f"Flip your screen to {direction} now!")
self._countdown_active = True
self.eink_toggle_btn.config(state='disabled')
self._run_countdown(countdown)
else:
self._do_eink_toggle()
def _run_countdown(self, remaining):
"""Tick the flip countdown, then perform the switch."""
if remaining > 0:
self.log_message(f"Switching in {remaining}...")
self.eink_toggle_btn.config(text=f"Flip now... {remaining}")
self.root.after(1000, self._run_countdown, remaining - 1)
else:
self._countdown_active = False
self.eink_toggle_btn.config(state='normal')
self._do_eink_toggle()
def _do_eink_toggle(self):
"""Perform the actual E-Ink display toggle."""
enabled = self.eink_enabled_var.get()