-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdisplay_latest.py
More file actions
1796 lines (1526 loc) · 83.6 KB
/
display_latest.py
File metadata and controls
1796 lines (1526 loc) · 83.6 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/python
# -*- coding:utf-8 -*-
import sys
import os
import time
import logging
import argparse
import signal
import socket
import subprocess
import threading
import json
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from PIL import Image, ImageDraw, ImageFont
import traceback
# Setup paths like in the test file
picdir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'pic')
libdir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'lib')
if os.path.exists(libdir):
sys.path.append(libdir)
from unified_epd_adapter import UnifiedEPD, EPDConfig
# Configure logging
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Global variables for exit handling
exit_requested = False
clear_on_exit_requested = True
# Global lock to prevent concurrent display operations
display_lock = threading.Lock()
def get_ip_address():
"""Get the device's IP address"""
try:
# Method 1: Try to get IP by connecting to a remote address (doesn't actually send data)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0)
try:
# Connect to a remote address (8.8.8.8 is Google's DNS)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
s.close()
except Exception:
pass
try:
# Method 2: Use hostname command
result = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
ips = result.stdout.strip().split()
if ips:
return ips[0] # Return first IP address
except Exception:
pass
try:
# Method 3: Parse ip route command
result = subprocess.run(['ip', 'route', 'get', '1'], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
lines = result.stdout.split('\n')
for line in lines:
if 'src' in line:
parts = line.split()
try:
src_idx = parts.index('src')
if src_idx + 1 < len(parts):
return parts[src_idx + 1]
except (ValueError, IndexError):
continue
except Exception:
pass
try:
# Method 4: Get hostname and resolve it
hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
if ip != '127.0.0.1':
return ip
except Exception:
pass
# Fallback
return "IP not found"
def signal_handler_clear_exit(signum, frame):
"""Handle Ctrl+C - exit with display clearing"""
global exit_requested, clear_on_exit_requested
logger.info("\n🛑 Ctrl+C pressed - exiting with display clearing...")
exit_requested = True
clear_on_exit_requested = True
class EinkDisplayHandler(FileSystemEventHandler):
def __init__(self, watched_folder="~/watched_files", clear_on_start=False, clear_on_exit=True, disable_startup_timer=None, disable_refresh_timer=None, refresh_interval_hours=None, startup_delay_minutes=None, enable_manufacturer_timing=None, enable_sleep_mode=None, display_type=None):
logger.info("DEBUG: EinkDisplayHandler.__init__ called")
self.watched_folder = Path(os.path.expanduser(watched_folder))
self.watched_folder.mkdir(exist_ok=True)
self.clear_on_start = clear_on_start
self.clear_on_exit = clear_on_exit
# Initialize e-paper display
if display_type is None:
logger.info("EinkDisplayHandler: no display_type CLI override, loading from config...")
display_type = EPDConfig.load_display_config()
else:
logger.info(f"EinkDisplayHandler: using CLI-provided display_type: {display_type}")
logger.info(f"Initializing display type: {display_type}")
self.epd = UnifiedEPD.create_display(display_type)
logger.info(f"Created EPD handler: {self.epd.__class__.__name__} for {display_type}") ##
self.epd.init()
# Clear screen on start if requested
if self.clear_on_start:
self.epd.Clear()
time.sleep(1)
# Configure display orientation
# Default orientation (will be overridden by settings or command line)
self.orientation = 'landscape'
# Configure image processing mode
self.image_crop_mode = 'center_crop' # 'center_crop' or 'fit_with_letterbox'
# Load settings
self.load_settings()
# Override settings with command line arguments (command line takes precedence)
# Command line arguments always take precedence when provided
logger.info(f"COMMAND LINE OVERRIDE - disable_startup_timer parameter: {disable_startup_timer}")
logger.info(f"COMMAND LINE OVERRIDE - disable_startup_timer parameter type: {type(disable_startup_timer)}")
# Store original settings file values for comparison
original_disable_startup_timer = self.disable_startup_timer
original_disable_refresh_timer = self.disable_refresh_timer
original_startup_delay_minutes = self.startup_delay_minutes
original_refresh_interval_hours = self.refresh_interval_hours
original_enable_manufacturer_timing = self.enable_manufacturer_timing
original_enable_sleep_mode = self.enable_sleep_mode
# Track if any command line arguments were used
command_line_args_used = False
# Convert string arguments to appropriate types
if disable_startup_timer is not None:
disable_startup_timer_bool = disable_startup_timer.lower() == 'true'
if disable_startup_timer_bool != original_disable_startup_timer:
self.disable_startup_timer = disable_startup_timer_bool
logger.info(f"Command line override: disable_startup_timer = {disable_startup_timer_bool} (was {original_disable_startup_timer})")
command_line_args_used = True
if disable_refresh_timer is not None:
disable_refresh_timer_bool = disable_refresh_timer.lower() == 'true'
if disable_refresh_timer_bool != original_disable_refresh_timer:
self.disable_refresh_timer = disable_refresh_timer_bool
logger.info(f"Command line override: disable_refresh_timer = {disable_refresh_timer_bool} (was {original_disable_refresh_timer})")
command_line_args_used = True
if startup_delay_minutes is not None and startup_delay_minutes != 1: # Only override if not default
self.startup_delay_minutes = startup_delay_minutes
logger.info(f"Command line override: startup_delay_minutes = {startup_delay_minutes} (was {original_startup_delay_minutes})")
command_line_args_used = True
if refresh_interval_hours is not None and refresh_interval_hours != 24: # Only override if not default
self.refresh_interval_hours = refresh_interval_hours
logger.info(f"Command line override: refresh_interval_hours = {refresh_interval_hours} (was {original_refresh_interval_hours})")
command_line_args_used = True
if enable_manufacturer_timing is not None:
enable_manufacturer_timing_bool = enable_manufacturer_timing.lower() == 'true'
if enable_manufacturer_timing_bool != original_enable_manufacturer_timing:
self.enable_manufacturer_timing = enable_manufacturer_timing_bool
logger.info(f"Command line override: enable_manufacturer_timing = {enable_manufacturer_timing_bool} (was {original_enable_manufacturer_timing})")
command_line_args_used = True
if enable_sleep_mode is not None:
enable_sleep_mode_bool = enable_sleep_mode.lower() == 'true'
if enable_sleep_mode_bool != original_enable_sleep_mode:
self.enable_sleep_mode = enable_sleep_mode_bool
logger.info(f"Command line override: enable_sleep_mode = {enable_sleep_mode_bool} (was {original_enable_sleep_mode})")
command_line_args_used = True
# Save settings to file if command line arguments were used
if command_line_args_used:
logger.info("Command line arguments used - saving updated settings to file")
self.save_settings_to_file()
# Load fonts (fallback to default if Font.ttc not available)
try:
self.font_small = ImageFont.truetype(os.path.join(picdir, 'Font.ttc'), 12)
self.font_medium = ImageFont.truetype(os.path.join(picdir, 'Font.ttc'), 16)
self.font_large = ImageFont.truetype(os.path.join(picdir, 'Font.ttc'), 20)
except:
self.font_small = ImageFont.load_default()
self.font_medium = ImageFont.load_default()
self.font_large = ImageFont.load_default()
# Timing control variables
self.startup_time = time.time()
self.last_file_update_time = time.time()
self.last_refresh_time = time.time()
self.current_displayed_file = None
self.last_welcome_screen_time = 0 # Track when welcome screen was last shown
# File processing variables (simplified with atomic operations)
self.last_processing_time = time.time()
# Startup timer control
self.startup_timer_active = True
self.manual_selection_during_startup = False
# E-ink display timing requirements (manufacturer specifications)
self.enable_manufacturer_timing = enable_manufacturer_timing
self.enable_sleep_mode = enable_sleep_mode
self.min_refresh_interval = 180 if enable_manufacturer_timing else 0 # Minimum 180 seconds between refreshes (if enabled)
# Set initial timing values from constructor parameters
# Only set these if they were explicitly provided (not None)
if refresh_interval_hours is not None:
self.refresh_interval_hours = refresh_interval_hours
if startup_delay_minutes is not None:
self.startup_delay_minutes = startup_delay_minutes
if disable_startup_timer is not None:
self.disable_startup_timer = disable_startup_timer
if disable_refresh_timer is not None:
self.disable_refresh_timer = disable_refresh_timer
# Start background threads for timing features (unless disabled)
self.startup_timer_thread = None
self.refresh_timer_thread = None
# Start startup timer if enabled
logger.info(f"About to start startup timer - disable_startup_timer: {self.disable_startup_timer}")
logger.info(f"About to start startup timer - disable_startup_timer type: {type(self.disable_startup_timer)}")
logger.info(f"About to start startup timer - disable_startup_timer == True: {self.disable_startup_timer == True}")
logger.info(f"About to start startup timer - disable_startup_timer == False: {self.disable_startup_timer == False}")
logger.info(f"About to start startup timer - not self.disable_startup_timer: {not self.disable_startup_timer}")
if not self.disable_startup_timer:
self.startup_timer_thread = threading.Thread(target=self.startup_timer_worker, daemon=True)
self.startup_timer_thread.start()
logger.info(f"Startup timer enabled: {self.startup_delay_minutes}-minute delay")
else:
logger.info("Startup timer disabled - NOT starting startup timer thread")
# Start refresh timer if enabled
if not self.disable_refresh_timer:
self.refresh_timer_thread = threading.Thread(target=self.refresh_timer_worker, daemon=True)
self.refresh_timer_thread.start()
logger.info(f"Refresh timer enabled: {self.refresh_interval_hours}-hour interval")
else:
logger.info("Refresh timer disabled")
logger.info(f"Monitoring folder: {self.watched_folder.absolute()}")
logger.info(f"E-ink display initialized - Size: {self.epd.width}x{self.epd.height} (native: {self.epd.native_orientation}, landscape: {self.epd.landscape_width}x{self.epd.landscape_height})")
logger.info(f"Display orientation: {self.orientation}")
logger.info(f"Auto-display uploads: {self.auto_display_uploads}")
logger.info(f"Manufacturer timing requirements: {'ENABLED' if self.enable_manufacturer_timing else 'DISABLED'}")
logger.info(f"Sleep mode: {'ENABLED' if self.enable_sleep_mode else 'DISABLED'}")
logger.info(f"Display dimensions - Width: {self.epd.landscape_width}, Height: {self.epd.landscape_height}")
logger.info(f"FINAL TIMING SETTINGS - Startup timer: {'DISABLED' if self.disable_startup_timer else 'ENABLED'}, Refresh timer: {'DISABLED' if self.disable_refresh_timer else 'ENABLED'}")
# Save display info for web server access
self._save_display_info()
def startup_timer_worker(self):
"""Worker thread for configurable startup display delay"""
try:
logger.info("Startup timer worker started")
logger.info(f"Startup timer worker - disable_startup_timer value: {self.disable_startup_timer}")
logger.info(f"Startup timer worker - startup_timer_active value: {self.startup_timer_active}")
# Check if startup timer is still enabled (in case it was disabled after thread started)
if self.disable_startup_timer:
logger.info("Startup timer was disabled after thread started - exiting worker")
return
# Ensure we have valid timing values
if self.startup_delay_minutes is None:
logger.warning("startup_delay_minutes is None, using default value of 1")
self.startup_delay_minutes = 1
# Show welcome screen first
logger.info("Showing welcome screen during startup delay...")
self.display_welcome_screen()
# Wait for the configured startup delay
startup_delay_seconds = self.startup_delay_minutes * 60
logger.info(f"Waiting {startup_delay_seconds} seconds for startup delay...")
time.sleep(startup_delay_seconds)
# Check if manual selection was made during startup
if self.manual_selection_during_startup:
logger.info("Manual selection made during startup - skipping automatic priority file display")
self.startup_timer_active = False
return
# Check if we should display the priority file
if not exit_requested and self.startup_timer_active:
logger.info(f"{self.startup_delay_minutes}-minute startup timer triggered - checking for priority file")
self.display_latest_file_if_no_updates()
else:
logger.info(f"Startup timer conditions not met - exit_requested: {exit_requested}, startup_timer_active: {self.startup_timer_active}")
except Exception as e:
logger.error(f"Startup timer worker error: {e}")
def refresh_timer_worker(self):
"""Worker thread for configurable refresh interval"""
try:
# Ensure we have valid timing values
if self.refresh_interval_hours is None:
logger.warning("refresh_interval_hours is None, using default value of 24")
self.refresh_interval_hours = 24
while not exit_requested:
# Wait for the configured refresh interval
refresh_interval_seconds = self.refresh_interval_hours * 3600
time.sleep(refresh_interval_seconds)
if not exit_requested:
logger.info(f"{self.refresh_interval_hours}-hour refresh timer triggered - refreshing display")
self.perform_display_refresh()
except Exception as e:
logger.error(f"Refresh timer worker error: {e}")
def display_latest_file_if_no_updates(self):
"""Display the priority file if no updates have happened since startup"""
try:
# Get the priority file to display
priority_file = self.get_priority_display_file()
if priority_file:
# Check if this file was updated since startup
file_mtime = priority_file.stat().st_mtime
if file_mtime < self.startup_time:
# No new files since startup, display the priority file
logger.info(f"No updates since startup - displaying priority file: {priority_file.name}")
self.display_file(priority_file)
self.current_displayed_file = priority_file
else:
logger.info("Files have been updated since startup - skipping startup display")
else:
logger.info("No priority file found for startup display")
except Exception as e:
logger.error(f"Error in startup display: {e}")
def perform_display_refresh(self):
"""Perform a configurable refresh by clearing and re-displaying current content"""
try:
# Ensure we have valid timing values
if self.refresh_interval_hours is None:
logger.warning("refresh_interval_hours is None, using default value of 24")
self.refresh_interval_hours = 24
logger.info(f"Performing {self.refresh_interval_hours}-hour display refresh...")
# Clear the display
self.epd.Clear()
time.sleep(1)
# Get the priority file to display
priority_file = self.get_priority_display_file()
if priority_file:
logger.info(f"Displaying priority file after refresh: {priority_file.name}")
self.display_file(priority_file)
self.current_displayed_file = priority_file
else:
# No priority file available, show welcome screen
logger.info("No priority file available after refresh - showing welcome screen")
self.display_welcome_screen()
self.last_refresh_time = time.time()
logger.info(f"{self.refresh_interval_hours}-hour refresh completed successfully")
except Exception as e:
logger.error(f"Error during display refresh: {e}")
# Try to reinitialize display if there was an error
try:
self.reinitialize_display()
logger.info("Display reinitialized after refresh error")
except Exception as reinit_error:
logger.error(f"Display reinitialization failed after refresh error: {reinit_error}")
def load_settings(self):
"""Load settings from the settings file"""
try:
# Use the same path as the upload server
settings_file = Path(os.path.expanduser('~/.config/rpi-einky')) / 'settings.json'
# Default settings that should always be present
default_settings = {
'auto_display_upload': True,
'image_crop_mode': 'center_crop',
'orientation': 'landscape',
'startup_delay_minutes': 1,
'refresh_interval_hours': 24,
'enable_startup_timer': True,
'enable_refresh_timer': True,
'enable_manufacturer_timing': False,
'enable_sleep_mode': True,
'selected_image': None
}
# Try to load existing settings
loaded_settings = {}
settings_need_update = False
if settings_file.exists():
try:
import json
with open(settings_file, 'r') as f:
content = f.read().strip()
if content: # File is not empty
loaded_settings = json.loads(content)
logger.info(f"Settings loaded from {settings_file}")
else:
# File is empty
logger.warning(f"Settings file {settings_file} is empty, using defaults")
settings_need_update = True
except (json.JSONDecodeError, FileNotFoundError) as e:
# File is corrupted or can't be read
logger.warning(f"Settings file {settings_file} is corrupted or unreadable: {e}, using defaults")
settings_need_update = True
else:
# File doesn't exist
logger.info(f"Settings file not found at {settings_file}, using defaults")
settings_need_update = True
# Check if all required settings are present
if not settings_need_update:
for key, default_value in default_settings.items():
if key not in loaded_settings:
logger.warning(f"Missing setting '{key}' in settings file, using default: {default_value}")
settings_need_update = True
break
# Merge loaded settings with defaults (loaded settings take precedence)
final_settings = default_settings.copy()
if loaded_settings:
final_settings.update(loaded_settings)
# Apply settings to instance variables
self.auto_display_uploads = final_settings['auto_display_upload']
self.image_crop_mode = final_settings['image_crop_mode']
self.orientation = final_settings['orientation']
self.startup_delay_minutes = final_settings['startup_delay_minutes']
self.refresh_interval_hours = final_settings['refresh_interval_hours']
self.disable_startup_timer = not final_settings['enable_startup_timer']
self.disable_refresh_timer = not final_settings['enable_refresh_timer']
self.enable_manufacturer_timing = final_settings['enable_manufacturer_timing']
self.enable_sleep_mode = final_settings['enable_sleep_mode']
self.selected_image = final_settings['selected_image']
logger.info(f"Final settings - Auto-display: {self.auto_display_uploads}, Crop mode: {self.image_crop_mode}, Orientation: {self.orientation}, Selected image: {self.selected_image}")
logger.info(f"Timing settings - Startup timer: {'DISABLED' if self.disable_startup_timer else 'ENABLED'}, Refresh timer: {'DISABLED' if self.disable_refresh_timer else 'ENABLED'}")
logger.info(f"Timing values - Startup delay: {self.startup_delay_minutes}min, Refresh: {self.refresh_interval_hours}h")
logger.info(f"LOAD_SETTINGS - disable_startup_timer value: {self.disable_startup_timer}")
logger.info(f"LOAD_SETTINGS - disable_startup_timer type: {type(self.disable_startup_timer)}")
# Update settings file if it was missing, empty, corrupted, or had missing fields
if settings_need_update:
try:
settings_file.parent.mkdir(parents=True, exist_ok=True)
import json
with open(settings_file, 'w') as f:
json.dump(final_settings, f, indent=2)
logger.info(f"Updated settings file with complete values: {list(final_settings.keys())}")
except Exception as e:
logger.error(f"Error updating settings file: {e}")
except Exception as e:
logger.error(f"Error loading settings: {e}")
# Fallback to defaults
self.auto_display_uploads = True
self.image_crop_mode = 'center_crop'
self.orientation = 'landscape'
self.startup_delay_minutes = 1
self.refresh_interval_hours = 24
self.disable_startup_timer = False
self.disable_refresh_timer = False
self.enable_manufacturer_timing = False
self.enable_sleep_mode = True
self.selected_image = None
def reload_settings(self):
"""Reload settings from file (useful when settings change)"""
self.load_settings()
logger.info(f"Settings reloaded - Auto-display: {self.auto_display_uploads}, Crop mode: {self.image_crop_mode}, Orientation: {self.orientation}")
# Update display info with new settings
self.update_display_info()
def restart_refresh_timer(self):
"""Restart the refresh timer with current settings"""
try:
logger.info(f"Restarting refresh timer - Current settings:")
logger.info(f" disable_refresh_timer: {self.disable_refresh_timer}")
logger.info(f" refresh_interval_hours: {self.refresh_interval_hours}")
logger.info(f" enable_manufacturer_timing: {self.enable_manufacturer_timing}")
# Stop existing timer thread if running
if hasattr(self, 'refresh_timer_thread') and self.refresh_timer_thread.is_alive():
logger.info("Stopping existing refresh timer thread")
# Note: We can't directly stop the thread, but we can set a flag
# The thread will exit on its own when it checks the condition
# Start new timer thread if enabled
if not self.disable_refresh_timer:
self.refresh_timer_thread = threading.Thread(target=self.refresh_timer_worker, daemon=True)
self.refresh_timer_thread.start()
logger.info(f"Refresh timer restarted: {self.refresh_interval_hours}-hour interval")
else:
logger.info("Refresh timer disabled - not starting new thread")
except Exception as e:
logger.error(f"Error restarting refresh timer: {e}")
def save_settings_to_file(self):
"""Save current settings to the settings file"""
try:
# Use the same path as the upload server
settings_file = Path(os.path.expanduser('~/.config/rpi-einky')) / 'settings.json'
logger.info(f"DEBUG: Saving settings to: {settings_file.absolute()}")
# Ensure the directory exists
settings_file.parent.mkdir(parents=True, exist_ok=True)
# Load existing settings if file exists
settings = {}
if settings_file.exists():
import json
try:
with open(settings_file, 'r') as f:
settings = json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
# If file is corrupted or empty, start with empty dict
settings = {}
# Update settings with current values
settings['auto_display_upload'] = self.auto_display_uploads
settings['image_crop_mode'] = self.image_crop_mode
settings['orientation'] = self.orientation
settings['startup_delay_minutes'] = self.startup_delay_minutes
settings['refresh_interval_hours'] = self.refresh_interval_hours
settings['enable_startup_timer'] = not self.disable_startup_timer
settings['enable_refresh_timer'] = not self.disable_refresh_timer
settings['enable_manufacturer_timing'] = self.enable_manufacturer_timing
settings['enable_sleep_mode'] = self.enable_sleep_mode
if hasattr(self, 'selected_image'):
settings['selected_image'] = self.selected_image
# Save settings back to file
with open(settings_file, 'w') as f:
json.dump(settings, f, indent=2)
logger.info(f"Settings saved to file: {list(settings.keys())}")
except Exception as e:
logger.error(f"Error saving settings to file: {e}")
def save_selected_image_setting(self, filename):
"""Save the selected image setting to the settings file"""
try:
# Use the same path as the upload server
settings_file = Path(os.path.expanduser('~/.config/rpi-einky')) / 'settings.json'
logger.info(f"DEBUG: Saving selected image setting to: {settings_file.absolute()}")
# Ensure the directory exists
settings_file.parent.mkdir(parents=True, exist_ok=True)
# Load existing settings if file exists
settings = {}
if settings_file.exists():
import json
try:
with open(settings_file, 'r') as f:
settings = json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
# If file is corrupted or empty, start with empty dict
settings = {}
# Update selected image
settings['selected_image'] = filename
# Save settings back to file
with open(settings_file, 'w') as f:
json.dump(settings, f, indent=2)
logger.info(f"Saved selected image setting: {filename}")
except Exception as e:
logger.error(f"Error saving selected image setting: {e}")
def apply_orientation(self, image):
"""Apply orientation transformation to an image based on current orientation setting"""
try:
orientation = getattr(self, 'orientation', 'landscape')
logger.info(f"Applying orientation: {orientation} to image size {image.size}")
if orientation == 'landscape':
# No rotation needed
logger.info(f"No rotation needed for landscape orientation")
return image
elif orientation == 'landscape_flipped':
# Rotate 180 degrees
logger.info(f"Rotating image 180 degrees for landscape_flipped")
return image.rotate(180)
elif orientation == 'portrait':
# Rotate 90 degrees clockwise
logger.info(f"Rotating image 90 degrees clockwise for portrait")
rotated = image.rotate(90, expand=True, fillcolor="white")
logger.info(f"Image rotated from {image.size} to {rotated.size}")
return rotated
elif orientation == 'portrait_flipped':
# Rotate 270 degrees clockwise (or 90 degrees counter-clockwise)
logger.info(f"Rotating image 270 degrees clockwise for portrait_flipped")
rotated = image.rotate(270, expand=True, fillcolor="white")
logger.info(f"Image rotated from {image.size} to {rotated.size}")
return rotated
else:
# Unknown orientation, return original
logger.warning(f"Unknown orientation: {orientation}, using landscape")
return image
except Exception as e:
logger.error(f"Error applying orientation {getattr(self, 'orientation', 'landscape')}: {e}")
return image
def get_latest_file(self):
"""Get the most recent file in the watched folder"""
try:
files = [f for f in self.watched_folder.glob('*') if f.is_file() and not f.name.startswith('.')]
if not files:
return None
# Sort by modification time (latest first)
latest_file = max(files, key=lambda f: f.stat().st_mtime)
return latest_file
except Exception as e:
logger.error(f"Error finding latest file: {e}")
return None
def get_priority_display_file(self):
"""Get the file that should be displayed based on priority logic"""
try:
logger.info(f"DEBUG: get_priority_display_file called, watched_folder: {self.watched_folder.absolute()}")
logger.info(f"DEBUG: selected_image: {self.selected_image}")
# Priority 1: Selected image (if set and exists) - takes precedence over uploads
if self.selected_image:
selected_file = self.watched_folder / self.selected_image
logger.info(f"DEBUG: Looking for selected file at: {selected_file.absolute()}")
if selected_file.exists():
logger.info(f"Priority: Selected image: {self.selected_image}")
return selected_file
else:
logger.warning(f"Selected image not found: {self.selected_image} - clearing invalid selection")
logger.info(f"DEBUG: File does not exist at: {selected_file.absolute()}")
# Clear the invalid selected image setting
self.selected_image = None
self.save_selected_image_setting(None)
# Priority 2: Latest file (fallback)
latest_file = self.get_latest_file()
logger.info(f"DEBUG: Latest file result: {latest_file}")
if latest_file:
logger.info(f"Priority: Latest file: {latest_file.name}")
return latest_file
# Priority 4: None (will show welcome screen)
logger.info("Priority: No file to display (will show welcome screen)")
return None
except Exception as e:
logger.error(f"Error getting priority display file: {e}")
return None
def validate_file(self, file_path):
"""Validate that file is complete and readable (simplified - atomic operations ensure completeness)"""
try:
# Check file exists and has content
if not file_path.exists() or file_path.stat().st_size == 0:
logger.warning(f"File is empty or doesn't exist: {file_path}")
return False
# For image files, try to open with PIL
if file_path.suffix.lower() in ['.jpg', '.jpeg', '.png', '.bmp', '.gif']:
try:
with Image.open(file_path) as img:
# Force loading to ensure file is complete
img.load()
logger.info(f"Image validation passed: {file_path.name} ({img.size[0]}x{img.size[1]}, {file_path.stat().st_size} bytes)")
return True
except Exception as e:
logger.error(f"Image validation failed: {file_path.name} - {e}")
return False
# For other files, just check readability
try:
with open(file_path, 'rb') as f:
f.read(1024) # Read first 1KB to check if readable
logger.info(f"File validation passed: {file_path.name} ({file_path.stat().st_size} bytes)")
return True
except Exception as e:
logger.error(f"File read validation failed: {file_path.name} - {e}")
return False
except Exception as e:
logger.error(f"File validation error: {file_path.name} - {e}")
return False
def display_buffer(self, image):
"""Display an image buffer on the e-ink display"""
try:
# Check manufacturer timing requirements if enabled
if self.enable_manufacturer_timing:
current_time = time.time()
if hasattr(self, 'last_refresh_time') and (current_time - self.last_refresh_time) < self.min_refresh_interval:
remaining_time = self.min_refresh_interval - (current_time - self.last_refresh_time)
logger.warning(f"Display refresh too soon. Must wait {remaining_time:.1f} more seconds (manufacturer requirement: 180s minimum)")
return False
# Wake up display if sleep mode is enabled
if self.enable_sleep_mode:
logger.info("Starting display operation (sleep mode enabled)...")
try:
self.epd.init()
time.sleep(0.5) # Brief delay after wake
except Exception as e:
logger.warning(f"Display init failed, attempting reinitialization: {e}")
self.reinitialize_display()
else:
logger.info("Starting display operation (sleep mode disabled)...")
# Display the image (orientation already applied)
logger.info("Calling epd.display()...")
self.epd.display(self.epd.getbuffer(image))
# Put display back to sleep mode if sleep mode is enabled
if self.enable_sleep_mode:
logger.info("Display completed - putting display to sleep...")
try:
self.epd.sleep()
except Exception as e:
logger.warning(f"Display sleep failed: {e}")
else:
logger.info("Display completed (sleep mode disabled)")
# Update last refresh time if manufacturer timing is enabled
if self.enable_manufacturer_timing:
self.last_refresh_time = time.time()
logger.info("Display operation completed successfully")
return True
except Exception as e:
logger.error(f"Display buffer error: {e}")
if "Bad file descriptor" in str(e) or "I/O error" in str(e):
logger.info("File descriptor error detected - attempting to reinitialize display...")
try:
self.reinitialize_display()
# Try again after reinitializing
# Don't apply orientation again - image is already oriented
self.epd.display(self.epd.getbuffer(image))
if self.enable_sleep_mode:
try:
self.epd.sleep() # Put to sleep after successful retry
except Exception as sleep_error:
logger.warning(f"Sleep after retry failed: {sleep_error}")
logger.info("Display operation completed successfully after reinitialization")
return True
except Exception as retry_error:
logger.error(f"Reinitialization and retry failed: {retry_error}")
return False
return False
def reinitialize_display(self):
"""Reinitialize the e-ink display"""
try:
logger.info("Reinitializing e-ink display...")
# Try to sleep first (if it fails, that's okay)
try:
self.epd.sleep()
except Exception as e:
logger.warning(f"Sleep during reinitialization failed: {e}")
# Wait a bit longer to ensure clean state
time.sleep(2)
# Reinitialize the display
try:
self.epd.init()
logger.info("Display reinitialized successfully")
except Exception as e:
logger.error(f"Display init failed during reinitialization: {e}")
# Try one more time after a longer delay
time.sleep(3)
self.epd.init()
logger.info("Display reinitialized successfully on second attempt")
except Exception as e:
logger.error(f"Display reinitialization failed: {e}")
raise
def on_created(self, event):
if event.is_directory:
return
file_path = Path(event.src_path)
logger.info(f"New file detected: {file_path.name}")
# Check if this is a command file from the commands directory
if 'commands' in str(file_path) and file_path.suffix == '.json':
self._process_command_file(file_path)
return
# Skip hidden files, thumbnails, and temporary files
if file_path.name.startswith('.') or '_thumb.' in file_path.name or file_path.name.endswith('.tmp'):
return
self._process_regular_file(file_path)
def on_modified(self, event):
if event.is_directory:
return
file_path = Path(event.src_path)
# Check if this is a command file from the commands directory
if 'commands' in str(file_path) and file_path.suffix == '.json':
logger.info(f"Command file modified: {file_path.name}")
self._process_command_file(file_path)
return
def _process_command_file(self, file_path):
"""Process command file from commands directory"""
try:
# Add a small delay to ensure file is fully written
time.sleep(0.1)
# Check if file exists and is readable
if not file_path.exists():
logger.warning(f"Command file does not exist: {file_path}")
return
# Read and execute the command
logger.info(f"Reading command file: {file_path}")
with open(file_path, 'r') as f:
command_data = json.load(f)
action = command_data.get('action')
filename = command_data.get('filename')
logger.info(f"Received command: {action} for file: {filename}")
if action == 'display_file' and filename:
# Display the requested file
target_file = self.watched_folder / filename
if target_file.exists():
logger.info(f"Executing display command for: {filename}")
self.display_file(target_file)
self.current_displayed_file = target_file
# Mark that manual selection was made during startup
if self.startup_timer_active:
logger.info("Manual selection detected during startup - will cancel automatic priority display")
self.manual_selection_during_startup = True
else:
logger.error(f"Command file not found: {filename}")
elif action == 'refresh_display':
# Refresh the display with current priority file
logger.info("Executing refresh display command")
# Always reload settings first
self.reload_settings()
# Restart refresh timer with new settings
self.restart_refresh_timer()
# During startup, still refresh display if this is an orientation change
# (orientation changes should always trigger immediate refresh)
if self.startup_timer_active:
logger.info("Settings change detected during startup - reloading settings and refreshing display")
# Get and display the priority file
priority_file = self.get_priority_display_file()
if priority_file:
logger.info(f"Refreshing display with priority file: {priority_file.name}")
self.display_file(priority_file)
self.current_displayed_file = priority_file
else:
logger.info("No priority file found for refresh - showing welcome screen")
self.display_welcome_screen()
elif action == 'clear_display':
# Clear the display
logger.info("Executing clear display command")
try:
if self.enable_sleep_mode:
self.epd.init()
self.epd.clear()
if self.enable_sleep_mode:
self.epd.sleep()
logger.info("Display cleared successfully")
self.current_displayed_file = None
except Exception as e:
logger.error(f"Error clearing display: {e}")
elif action == 'show_welcome_screen':
# Display welcome screen with auto-revert
logger.info("Executing show welcome screen command")
try:
self.display_welcome_screen_with_revert(force=True)
logger.info("Welcome screen displayed successfully")
except Exception as e:
logger.error(f"Error displaying welcome screen: {e}")
elif action == 'get_display_info':
# Send display info response
logger.info("Executing get display info command")
self._send_display_info_response()
elif action == 'update_display_info':
# Update display info with current settings
logger.info("Executing update display info command")
self.update_display_info()
else:
logger.warning(f"Unknown command action: {action}")
# Clean up command file
file_path.unlink()
except Exception as e:
logger.error(f"Error processing command file: {e}")
# Clean up command file even on error
try:
file_path.unlink()
except:
pass
def _process_regular_file(self, file_path):
"""Process regular file uploads (simplified - atomic operations ensure file completeness)"""
# Update timing variables
self.last_file_update_time = time.time()
self.last_processing_time = time.time()
logger.info(f"File watcher detected file: {file_path.name}, size: {file_path.stat().st_size} bytes")
# Brief delay to ensure file system operations complete
time.sleep(0.1)
# Validate file is complete and readable (no retry needed with atomic operations)
if not self.validate_file(file_path):
logger.error(f"File validation failed: {file_path.name}")
self.display_error(file_path.name, "File validation failed")
return
logger.info(f"File validated successfully: {file_path.name}")
# Check if auto-display is enabled
if self.auto_display_uploads:
try:
# Set this as the current displayed file first
self.current_displayed_file = file_path
# Mark that a new file was uploaded during startup
if self.startup_timer_active:
logger.info("New file upload detected during startup - will cancel automatic priority display")
self.manual_selection_during_startup = True
success = self.display_file(file_path)
if success:
logger.info(f"Auto-displayed file: {file_path.name}")
elif self.enable_manufacturer_timing:
logger.warning(f"Display operation failed for {file_path.name} - likely due to timing restrictions")
# Queue for retry after minimum interval
retry_time = self.min_refresh_interval - (time.time() - self.last_refresh_time)
if retry_time > 0:
logger.info(f"Will retry displaying {file_path.name} in {retry_time:.1f} seconds")
threading.Timer(retry_time, self.retry_display_file, args=[file_path]).start()
else:
logger.warning(f"Display operation failed for {file_path.name}")
except Exception as e:
logger.error(f"Error auto-displaying file {file_path.name}: {e}")
self.display_error(file_path.name, str(e))
else:
logger.info(f"Auto-display disabled - file {file_path.name} not displayed")