mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-12 15:44:58 +00:00
863 lines
35 KiB
Python
863 lines
35 KiB
Python
# display.py - FIXED VERSION (v2 + wrap_text/throttle optimizations)
|
||
# - Un seul thread d’update EPD à la fois (pas d’accumulation)
|
||
# - Full refresh déplacé dans le worker
|
||
# - Circuit breaker : désactive temporairement l’EPD après échecs répétés
|
||
# - Timeouts & logs conservés / améliorés
|
||
# - Reste compatible avec le code appelant
|
||
# - NEW: comment layout cache + throttling to reduce wrap_text calls
|
||
|
||
import threading
|
||
import time
|
||
import os
|
||
import signal
|
||
import logging
|
||
import random
|
||
import sys
|
||
import traceback
|
||
import json
|
||
import subprocess
|
||
from typing import Dict, List, Optional, Any, Tuple
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
from init_shared import shared_data
|
||
from comment import CommentAI
|
||
from logger import Logger
|
||
|
||
logger = Logger(name="display.py", level=logging.DEBUG)
|
||
|
||
|
||
class Display:
|
||
"""Optimized display manager with robust error handling and recovery"""
|
||
|
||
# CRITICAL: Timeout constants
|
||
SEMAPHORE_TIMEOUT = 5.0 # Max time to wait for semaphore
|
||
EPD_OPERATION_TIMEOUT = 10.0 # Max time for EPD operation (indicative)
|
||
LOOP_ITERATION_TIMEOUT = 30.0 # Max time for one display loop
|
||
RECOVERY_COOLDOWN = 60.0 # Min time between hard resets
|
||
|
||
# Circuit breaker
|
||
MAX_CONSECUTIVE_FAILURES = 6 # Après N échecs, on coupe l’EPD
|
||
STUCK_RECOVERY_S = 120.0 # Si bloqué > 120s, on tente recovery
|
||
|
||
def __init__(self, shared_data):
|
||
self.shared_data = shared_data
|
||
self.config = self.shared_data.config
|
||
self.comment_ai = CommentAI()
|
||
self.epd_enabled = self.config.get("epd_enabled", True)
|
||
|
||
self.epd = self.shared_data.epd if self.epd_enabled else None
|
||
|
||
if self.config.get("epd_type") == "epd2in13_V2":
|
||
self.shared_data.width = 120
|
||
else:
|
||
self.shared_data.width = self.shared_data.width
|
||
|
||
self.semaphore = threading.Semaphore(self.shared_data.semaphore_slots)
|
||
|
||
# Recovery tracking
|
||
self.last_successful_update = time.time()
|
||
self.last_recovery_attempt = 0
|
||
self.consecutive_failures = 0
|
||
self.total_updates = 0
|
||
self.failed_updates = 0
|
||
|
||
# Update worker (évite l’empilement)
|
||
self._upd_lock = threading.Lock()
|
||
self._upd_thread: Optional[threading.Thread] = None
|
||
self._upd_stuck_since: Optional[float] = None
|
||
self._last_full_refresh = time.time()
|
||
|
||
# Screen configuration
|
||
self.screen_reversed = self.shared_data.screen_reversed
|
||
self.web_screen_reversed = self.shared_data.web_screen_reversed
|
||
|
||
# Network status with caching
|
||
self.ssid = ""
|
||
self.current_ip = ""
|
||
self.show_ip_on_screen = False
|
||
self.show_ssid_on_screen = False
|
||
self._network_cache = {'ip': None, 'ssid': None, 'timestamp': 0}
|
||
self._network_cache_ttl = 30
|
||
|
||
self._connection_cache = {'data': None, 'timestamp': 0}
|
||
self._connection_cache_ttl = 10
|
||
|
||
self._data_count_cache = {'count': 0, 'timestamp': 0}
|
||
self._data_count_cache_ttl = 60
|
||
|
||
# Display name
|
||
self.bjorn_name = self.shared_data.bjorn_name
|
||
self.previous_bjorn_name = None
|
||
self.calculate_font_to_fit()
|
||
|
||
# Full refresh settings
|
||
self.fullrefresh_activated = self.shared_data.fullrefresh_activated
|
||
self.fullrefresh_delay = self.shared_data.fullrefresh_delay
|
||
|
||
# Cache for expensive operations
|
||
self._stats_cache = {'data': None, 'timestamp': 0}
|
||
self._stats_cache_ttl = 5.0
|
||
|
||
# NEW: comment wrap/layout cache + throttle
|
||
self._comment_layout_cache = {"key": None, "lines": [], "ts": 0.0}
|
||
# Recompute at most once per this interval unless the key changes
|
||
self._comment_layout_min_interval = max(0.8, float(self.shared_data.screen_delay))
|
||
|
||
# Initialize display
|
||
try:
|
||
if self.epd_enabled:
|
||
self.shared_data.epd.init_full_update()
|
||
logger.info("EPD display initialization complete")
|
||
|
||
if self.shared_data.showstartupipssid:
|
||
ip_address, ssid = self.get_network_info()
|
||
self.display_startup_ip(ip_address, ssid)
|
||
time.sleep(self.shared_data.startup_splash_duration)
|
||
else:
|
||
logger.info("EPD display disabled - running in web-only mode")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error during display initialization: {e}")
|
||
if self.epd_enabled:
|
||
# On remonte si EPD était censé être actif (cohérent avec l’existant)
|
||
raise
|
||
else:
|
||
logger.warning("EPD initialization failed but continuing in web-only mode")
|
||
|
||
self.shared_data.bjorn_status_text2 = "Awakening..."
|
||
|
||
# Start background threads
|
||
self._start_background_threads()
|
||
|
||
def _start_background_threads(self):
|
||
"""Start all background update threads"""
|
||
self.main_image_thread = threading.Thread(
|
||
target=self.update_main_image, daemon=True, name="DisplayImageUpdater"
|
||
)
|
||
self.main_image_thread.start()
|
||
|
||
self.stats_update_thread = threading.Thread(
|
||
target=self.schedule_stats_update, daemon=True, name="DisplayStatsUpdater"
|
||
)
|
||
self.stats_update_thread.start()
|
||
|
||
# ---- Positioning helpers ----
|
||
|
||
def px(self, x_ref: int) -> int:
|
||
return int(x_ref * self.shared_data.width / self.shared_data.ref_width)
|
||
|
||
def py(self, y_ref: int) -> int:
|
||
return int(y_ref * self.shared_data.height / self.shared_data.ref_height)
|
||
|
||
# ---- Font management ----
|
||
|
||
def calculate_font_to_fit(self):
|
||
default_font_size = 13
|
||
default_font_path = self.shared_data.font_viking_path
|
||
default_font = ImageFont.truetype(default_font_path, default_font_size)
|
||
max_text_width, _ = default_font.getsize("BJORN")
|
||
|
||
self.font_to_use = self.get_font_to_fit(
|
||
self.bjorn_name, default_font_path, max_text_width, default_font_size
|
||
)
|
||
|
||
def get_font_to_fit(self, text: str, font_path: str, max_width: int, max_font_size: int):
|
||
font_size = max_font_size
|
||
font = ImageFont.truetype(font_path, font_size)
|
||
text_width, _ = font.getsize(text)
|
||
|
||
while text_width > max_width and font_size > 5:
|
||
font_size -= 1
|
||
font = ImageFont.truetype(font_path, font_size)
|
||
text_width, _ = font.getsize(text)
|
||
|
||
return font
|
||
|
||
def _pad_for_v2(self, img: Image.Image) -> Image.Image:
|
||
if self.config.get("epd_type") == "epd2in13_V2" and img.size == (120, 250):
|
||
padded = Image.new('1', (122, 250), 1)
|
||
padded.paste(img, (1, 0))
|
||
return padded
|
||
return img
|
||
|
||
# ---- Network status with caching ----
|
||
|
||
def get_network_info(self) -> Tuple[str, str]:
|
||
now = time.time()
|
||
if self._network_cache['timestamp'] + self._network_cache_ttl > now:
|
||
return self._network_cache['ip'], self._network_cache['ssid']
|
||
|
||
ip = self.get_ip_address()
|
||
ssid = self.get_ssids()
|
||
self._network_cache = {'ip': ip, 'ssid': ssid, 'timestamp': now}
|
||
return ip, ssid
|
||
|
||
def get_ip_address(self) -> str:
|
||
try:
|
||
iface_list = self._as_list(
|
||
getattr(self.shared_data, "ip_iface_priority", ["wlan0", "eth0"]),
|
||
default=["wlan0", "eth0"]
|
||
)
|
||
|
||
for iface in iface_list:
|
||
result = subprocess.run(
|
||
['ip', 'addr', 'show', iface],
|
||
capture_output=True, text=True, timeout=2
|
||
)
|
||
if result.returncode == 0:
|
||
for line in result.stdout.split('\n'):
|
||
if 'inet ' in line:
|
||
return line.split()[1].split('/')[0]
|
||
|
||
return "No IP"
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error getting IP address: {e}")
|
||
return "Error"
|
||
|
||
def get_ssids(self) -> str:
|
||
try:
|
||
result = subprocess.run(
|
||
['iwgetid', '-r'],
|
||
capture_output=True, text=True, timeout=2
|
||
)
|
||
if result.returncode == 0:
|
||
return result.stdout.strip() or "No Wi-Fi"
|
||
return "No Wi-Fi"
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error getting SSID: {e}")
|
||
return "Error"
|
||
|
||
def check_all_connections(self) -> Dict[str, bool]:
|
||
now = time.time()
|
||
|
||
if self._connection_cache['data'] and (now - self._connection_cache['timestamp']) < self._connection_cache_ttl:
|
||
return self._connection_cache['data']
|
||
|
||
results = {}
|
||
|
||
try:
|
||
ip_neigh = subprocess.run(['ip', 'neigh', 'show'],
|
||
capture_output=True, text=True, timeout=2)
|
||
neigh_output = ip_neigh.stdout if ip_neigh.returncode == 0 else ""
|
||
|
||
iwgetid = subprocess.run(['iwgetid', '-r'],
|
||
capture_output=True, text=True, timeout=1)
|
||
results['wifi'] = bool(iwgetid.returncode == 0 and iwgetid.stdout.strip())
|
||
|
||
bt_ifaces = self._as_list(
|
||
getattr(self.shared_data, "neigh_bluetooth_ifaces", ["pan0", "bnep0"]),
|
||
default=["pan0", "bnep0"]
|
||
)
|
||
results['bluetooth'] = any(f'dev {iface}' in neigh_output for iface in bt_ifaces)
|
||
|
||
eth_iface = self._as_str(
|
||
getattr(self.shared_data, "neigh_ethernet_iface", "eth0"), "eth0"
|
||
)
|
||
results['ethernet'] = f'dev {eth_iface}' in neigh_output
|
||
|
||
usb_iface = self._as_str(
|
||
getattr(self.shared_data, "neigh_usb_iface", "usb0"), "usb0"
|
||
)
|
||
results['usb'] = f'dev {usb_iface}' in neigh_output
|
||
|
||
except Exception as e:
|
||
logger.error(f"Connection check failed: {e}")
|
||
results = {'wifi': False, 'bluetooth': False, 'ethernet': False, 'usb': False}
|
||
|
||
self._connection_cache = {'data': results, 'timestamp': now}
|
||
return results
|
||
|
||
def is_manual_mode(self) -> bool:
|
||
return self.shared_data.manual_mode
|
||
|
||
def get_data_count(self) -> int:
|
||
now = time.time()
|
||
|
||
if (now - self._data_count_cache['timestamp']) < self._data_count_cache_ttl:
|
||
return self._data_count_cache['count']
|
||
|
||
try:
|
||
total = sum(
|
||
len(files) for r, d, files in os.walk(self.shared_data.data_stolen_dir)
|
||
)
|
||
self._data_count_cache = {'count': total, 'timestamp': now}
|
||
return total
|
||
except Exception as e:
|
||
logger.error(f"Error counting data files: {e}")
|
||
return self._data_count_cache.get('count', 0)
|
||
|
||
def display_startup_ip(self, ip_address: str, ssid: str):
|
||
if not self.epd_enabled:
|
||
logger.debug("Skipping EPD startup display (EPD disabled)")
|
||
return
|
||
|
||
try:
|
||
image = Image.new('1', (self.shared_data.width, self.shared_data.height), 255)
|
||
draw = ImageDraw.Draw(image)
|
||
|
||
draw.text((self.px(37), self.py(5)), "BJORN", font=self.shared_data.font_viking, fill=0)
|
||
|
||
message = f"Awakening...\nIP: {ip_address}"
|
||
draw.text(
|
||
(self.px(10), int(self.shared_data.height / 2)),
|
||
message, font=self.shared_data.font_arial14, fill=0
|
||
)
|
||
|
||
draw.text(
|
||
(self.px(10), int(self.shared_data.height / 2) + 40),
|
||
f"SSID: {ssid}", font=self.shared_data.font_arial9, fill=0
|
||
)
|
||
|
||
draw.rectangle((0, 1, self.shared_data.width - 1, self.shared_data.height - 1), outline=0)
|
||
|
||
if self.screen_reversed:
|
||
image = image.transpose(Image.ROTATE_180)
|
||
|
||
image = self._pad_for_v2(image)
|
||
|
||
self.shared_data.epd.display_partial(image)
|
||
if self.shared_data.double_partial_refresh:
|
||
self.shared_data.epd.display_partial(image)
|
||
|
||
logger.info(f"Displayed startup IP: {ip_address}, SSID: {ssid}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error displaying startup IP: {e}")
|
||
|
||
def schedule_stats_update(self):
|
||
while not self.shared_data.display_should_exit:
|
||
try:
|
||
self.update_stats_from_db()
|
||
time.sleep(self.shared_data.shared_update_interval)
|
||
except Exception as e:
|
||
logger.error(f"Error in stats update: {e}")
|
||
time.sleep(self.shared_data.shared_update_interval)
|
||
continue
|
||
|
||
def update_stats_from_db(self):
|
||
"""Update statistics with timeout protection"""
|
||
acquired = self.semaphore.acquire(timeout=self.SEMAPHORE_TIMEOUT)
|
||
if not acquired:
|
||
logger.warning("Failed to acquire semaphore for stats update - skipping")
|
||
return
|
||
|
||
try:
|
||
stats = self.shared_data.db.get_display_stats()
|
||
|
||
self.shared_data.port_count = stats.get('total_open_ports', 0)
|
||
self.shared_data.target_count = stats.get('alive_hosts_count', 0)
|
||
self.shared_data.network_kb_count = stats.get('all_known_hosts_count', 0)
|
||
self.shared_data.vuln_count = stats.get('vulnerabilities_count', 0)
|
||
self.shared_data.cred_count = stats.get('credentials_count', 0)
|
||
self.shared_data.attacks_count = stats.get('actions_count', 0)
|
||
self.shared_data.zombie_count = stats.get('zombie_count', 0)
|
||
|
||
self.current_ip, self.ssid = self.get_network_info()
|
||
self.shared_data.data_count = self.get_data_count()
|
||
self.shared_data.update_stats()
|
||
|
||
connections = self.check_all_connections()
|
||
self.shared_data.wifi_connected = connections['wifi']
|
||
self.shared_data.usb_active = connections['usb']
|
||
self.shared_data.bluetooth_active = connections['bluetooth']
|
||
self.shared_data.ethernet_active = connections['ethernet']
|
||
|
||
self.shared_data.manual_mode = self.is_manual_mode()
|
||
self.manual_mode_txt = "M" if self.shared_data.manual_mode else "A"
|
||
|
||
self.show_ip_on_screen = self.shared_data.showiponscreen
|
||
self.show_ssid_on_screen = self.shared_data.showssidonscreen
|
||
self.bjorn_name = self.shared_data.bjorn_name
|
||
|
||
if self.bjorn_name != self.previous_bjorn_name:
|
||
self.calculate_font_to_fit()
|
||
self.previous_bjorn_name = self.bjorn_name
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error updating stats from DB: {e}")
|
||
finally:
|
||
self.semaphore.release()
|
||
|
||
def update_main_image(self):
|
||
while not self.shared_data.display_should_exit:
|
||
try:
|
||
self.shared_data.update_image_randomizer()
|
||
if self.shared_data.imagegen:
|
||
self.main_image = self.shared_data.imagegen
|
||
else:
|
||
logger.debug("No image generated for current status")
|
||
|
||
time.sleep(
|
||
random.uniform(
|
||
self.shared_data.image_display_delaymin,
|
||
self.shared_data.image_display_delaymax
|
||
)
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in update_main_image: {e}")
|
||
time.sleep(5)
|
||
|
||
def _as_list(self, value: Any, default: Optional[List] = None) -> List:
|
||
if default is None:
|
||
default = []
|
||
|
||
try:
|
||
if isinstance(value, list):
|
||
return value
|
||
if isinstance(value, str):
|
||
try:
|
||
obj = json.loads(value)
|
||
if isinstance(obj, list):
|
||
return obj
|
||
except:
|
||
pass
|
||
return [x.strip() for x in value.split(",") if x.strip()]
|
||
return list(value) if value is not None else default
|
||
except:
|
||
return default
|
||
|
||
def _as_str(self, value: Any, default: str = "") -> str:
|
||
if isinstance(value, str):
|
||
return value
|
||
try:
|
||
return str(value) if value is not None else default
|
||
except:
|
||
return default
|
||
|
||
def _as_int(self, value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except:
|
||
return default
|
||
|
||
def get_frise_position(self) -> Tuple[int, int]:
|
||
display_type = self.config.get("epd_type", "default")
|
||
|
||
if display_type == "epd2in7":
|
||
x = self._as_int(getattr(self.shared_data, "frise_epd2in7_x", 50), 50)
|
||
y = self._as_int(getattr(self.shared_data, "frise_epd2in7_y", 160), 160)
|
||
else:
|
||
x = self._as_int(getattr(self.shared_data, "frise_default_x", 0), 0)
|
||
y = self._as_int(getattr(self.shared_data, "frise_default_y", 160), 160)
|
||
|
||
return self.px(x), self.py(y)
|
||
|
||
def display_comment(self, status: str):
|
||
params = getattr(self.shared_data, "comment_params", {}) or {}
|
||
comment = self.comment_ai.get_comment(status, params=params)
|
||
if comment:
|
||
self.shared_data.bjorn_says = comment
|
||
self.shared_data.bjorn_status_text = self.shared_data.bjorn_orch_status
|
||
|
||
def clear_screen(self):
|
||
if self.epd_enabled:
|
||
try:
|
||
self.shared_data.epd.clear()
|
||
except Exception as e:
|
||
logger.error(f"Error clearing EPD: {e}")
|
||
else:
|
||
logger.debug("Skipping EPD clear (EPD disabled)")
|
||
|
||
# ========================================================================
|
||
# MAIN DISPLAY LOOP WITH ROBUST ERROR HANDLING
|
||
# ========================================================================
|
||
|
||
def run(self):
|
||
"""Main display rendering loop with active watchdog and recovery"""
|
||
self.manual_mode_txt = ""
|
||
|
||
try:
|
||
while not self.shared_data.display_should_exit:
|
||
iteration_start = time.time()
|
||
|
||
try:
|
||
success = self._execute_display_update_with_timeout()
|
||
|
||
if success:
|
||
self.last_successful_update = time.time()
|
||
self.consecutive_failures = 0
|
||
self.total_updates += 1
|
||
else:
|
||
self.consecutive_failures += 1
|
||
self.failed_updates += 1
|
||
logger.warning(f"Display update failed ({self.consecutive_failures} consecutive failures)")
|
||
|
||
# Watchdog & recovery
|
||
time_since_success = time.time() - self.last_successful_update
|
||
if (self._upd_stuck_since and (time.time() - self._upd_stuck_since) > self.STUCK_RECOVERY_S) \
|
||
or self.consecutive_failures >= 3:
|
||
logger.error("Watchdog: EPD appears stuck or repeated failures - attempting recovery")
|
||
self._attempt_recovery()
|
||
|
||
# Circuit breaker: disable EPD after many failures
|
||
if self.epd_enabled and self.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES:
|
||
logger.error("Too many consecutive display failures - disabling EPD (graceful degradation)")
|
||
self.epd_enabled = False # web-only mode until next recovery success
|
||
# Do not reference self.shared_data.epd when disabled
|
||
|
||
# Health logs (légers)
|
||
if self.total_updates % 100 == 0 and self.total_updates > 0:
|
||
success_rate = ((self.total_updates - self.failed_updates) / self.total_updates) * 100
|
||
try:
|
||
fds = len(os.listdir(f"/proc/{os.getpid()}/fd"))
|
||
except Exception:
|
||
fds = -1
|
||
# logger.info(f"Display stats: {self.total_updates} updates, {success_rate:.1f}% success "
|
||
# f"(threads={threading.active_count()}, fds={fds})")
|
||
|
||
# Delay before next update
|
||
time.sleep(self.shared_data.screen_delay)
|
||
|
||
except KeyboardInterrupt:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Unexpected error in display loop: {e}")
|
||
logger.error(traceback.format_exc())
|
||
time.sleep(5)
|
||
|
||
finally:
|
||
self._cleanup_display()
|
||
|
||
def _execute_display_update_with_timeout(self) -> bool:
|
||
"""
|
||
Lance au plus un worker d’update. Si un précédent est encore vivant,
|
||
on ne relance pas (évite l’empilement).
|
||
"""
|
||
with self._upd_lock:
|
||
if self._upd_thread and self._upd_thread.is_alive():
|
||
logger.warning("Previous EPD update still running; skipping this cycle")
|
||
# marquer comme potentiellement bloqué
|
||
if self._upd_stuck_since is None:
|
||
self._upd_stuck_since = time.time()
|
||
return False
|
||
|
||
# démarrer un nouveau worker
|
||
self._upd_thread = threading.Thread(
|
||
target=self._do_display_update, daemon=True, name="EPDUpdate"
|
||
)
|
||
self._upd_thread.start()
|
||
|
||
# Attente bornée
|
||
self._upd_thread.join(timeout=self.LOOP_ITERATION_TIMEOUT)
|
||
if self._upd_thread.is_alive():
|
||
logger.error(f"Display update timed out after {self.LOOP_ITERATION_TIMEOUT}s")
|
||
if self._upd_stuck_since is None:
|
||
self._upd_stuck_since = time.time()
|
||
return False
|
||
|
||
# terminé
|
||
self._upd_stuck_since = None
|
||
return True
|
||
|
||
def _do_display_update(self):
|
||
"""Perform the actual display update (single worker)"""
|
||
try:
|
||
# Full refresh (si activé) AVANT rendu
|
||
if self.epd_enabled and self.fullrefresh_activated:
|
||
now = time.time()
|
||
if now - self._last_full_refresh >= self.fullrefresh_delay:
|
||
try:
|
||
self.shared_data.epd.clear()
|
||
logger.info("Display cleared for full refresh (in worker)")
|
||
self._last_full_refresh = now
|
||
except Exception as e:
|
||
logger.error(f"Full refresh failed: {e}")
|
||
# On continue en essayant l’update partiel
|
||
|
||
if self.epd_enabled:
|
||
# Init du mode partiel
|
||
try:
|
||
self.shared_data.epd.init_partial_update()
|
||
except Exception as e:
|
||
logger.error(f"EPD init_partial_update failed: {e}")
|
||
raise
|
||
|
||
self.display_comment(self.shared_data.bjorn_orch_status)
|
||
|
||
image = self._render_display()
|
||
|
||
if self.screen_reversed:
|
||
image = image.transpose(Image.ROTATE_180)
|
||
|
||
image = self._pad_for_v2(image)
|
||
|
||
if self.epd_enabled:
|
||
try:
|
||
self.shared_data.epd.display_partial(image)
|
||
if self.shared_data.double_partial_refresh:
|
||
self.shared_data.epd.display_partial(image)
|
||
except Exception as e:
|
||
logger.error(f"EPD display_partial failed: {e}")
|
||
raise
|
||
|
||
# Toujours sauver le screenshot (web)
|
||
self._save_screenshot(image)
|
||
|
||
# logger.debug("Display update completed successfully")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in display update: {e}")
|
||
logger.error(traceback.format_exc())
|
||
# laisser l’exception remonter pour le comptage des échecs
|
||
raise
|
||
|
||
def _attempt_recovery(self):
|
||
"""Attempt to recover from display failures"""
|
||
current_time = time.time()
|
||
|
||
# Enforce cooldown between recovery attempts
|
||
if current_time - self.last_recovery_attempt < self.RECOVERY_COOLDOWN:
|
||
time_remaining = self.RECOVERY_COOLDOWN - (current_time - self.last_recovery_attempt)
|
||
logger.warning(f"Recovery cooldown active ({time_remaining:.1f}s remaining)")
|
||
return
|
||
|
||
self.last_recovery_attempt = current_time
|
||
logger.warning("=== Attempting display recovery ===")
|
||
|
||
try:
|
||
if self.epd_enabled:
|
||
# Try hard reset with timeout
|
||
logger.info("Performing EPD hard reset...")
|
||
reset_thread = threading.Thread(
|
||
target=self.shared_data.epd.hard_reset,
|
||
daemon=True
|
||
)
|
||
reset_thread.start()
|
||
reset_thread.join(timeout=15.0)
|
||
|
||
if reset_thread.is_alive():
|
||
logger.error("Hard reset timed out - recovery failed")
|
||
else:
|
||
logger.info("Hard reset completed")
|
||
self.consecutive_failures = 0
|
||
time.sleep(2) # Let hardware stabilize
|
||
else:
|
||
# Si EPD désactivé, tenter une réactivation soft
|
||
try:
|
||
self.shared_data.epd.init_full_update()
|
||
self.epd_enabled = True
|
||
logger.info("EPD re-enabled after recovery attempt")
|
||
self.consecutive_failures = 0
|
||
except Exception as e:
|
||
logger.error(f"Re-enable EPD failed: {e}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Recovery failed: {e}")
|
||
logger.error(traceback.format_exc())
|
||
|
||
def _render_display(self) -> Image.Image:
|
||
"""Render complete display image"""
|
||
image = Image.new('1', (self.shared_data.width, self.shared_data.height), 255)
|
||
draw = ImageDraw.Draw(image)
|
||
|
||
draw.text((self.px(37), self.py(5)), self.bjorn_name, font=self.font_to_use, fill=0)
|
||
draw.text((self.px(105), self.py(171)), self.manual_mode_txt, font=self.shared_data.font_arial14, fill=0)
|
||
|
||
self._draw_connection_icons(image)
|
||
self._draw_battery_status(image)
|
||
self._draw_statistics(image, draw)
|
||
|
||
self.shared_data.update_bjorn_status()
|
||
image.paste(self.shared_data.bjorn_status_image, (self.px(3), self.py(60)))
|
||
|
||
self._draw_status_text(draw)
|
||
self._draw_decorations(image, draw)
|
||
self._draw_comment_text(draw)
|
||
|
||
if hasattr(self, "main_image") and self.main_image is not None:
|
||
self.shared_data.bjorn_character = self.main_image
|
||
image.paste(self.main_image, (self.shared_data.x_center1, self.shared_data.y_bottom1 - 1))
|
||
|
||
return image
|
||
|
||
def _draw_connection_icons(self, image: Image.Image):
|
||
wifi_width = self.px(16)
|
||
bluetooth_width = self.px(9)
|
||
usb_width = self.px(9)
|
||
ethernet_width = self.px(12)
|
||
|
||
start_x = self.px(3)
|
||
spacing = self.px(6)
|
||
|
||
active_icons = []
|
||
if self.shared_data.wifi_connected:
|
||
active_icons.append(('wifi', self.shared_data.wifi, wifi_width))
|
||
if self.shared_data.bluetooth_active:
|
||
active_icons.append(('bluetooth', self.shared_data.bluetooth, bluetooth_width))
|
||
if self.shared_data.usb_active:
|
||
active_icons.append(('usb', self.shared_data.usb, usb_width))
|
||
if self.shared_data.ethernet_active:
|
||
active_icons.append(('ethernet', self.shared_data.ethernet, ethernet_width))
|
||
|
||
current_x = start_x
|
||
for i, (name, icon, width) in enumerate(active_icons):
|
||
if len(active_icons) == 4 and i == 3:
|
||
image.paste(icon, (self.px(92), self.py(4)))
|
||
else:
|
||
y_pos = self.py(3) if name == 'wifi' else self.py(4)
|
||
image.paste(icon, (int(current_x), y_pos))
|
||
current_x += width + spacing
|
||
|
||
def _draw_battery_status(self, image: Image.Image):
|
||
battery_pos = (self.px(110), self.py(3))
|
||
battery_status = self.shared_data.battery_status
|
||
|
||
if battery_status == 101:
|
||
image.paste(self.shared_data.battery_charging, battery_pos)
|
||
else:
|
||
battery_icons = {
|
||
(0, 24): self.shared_data.battery0,
|
||
(25, 49): self.shared_data.battery25,
|
||
(50, 74): self.shared_data.battery50,
|
||
(75, 89): self.shared_data.battery75,
|
||
(90, 100): self.shared_data.battery100,
|
||
}
|
||
|
||
for (lower, upper), icon in battery_icons.items():
|
||
if lower <= battery_status <= upper:
|
||
image.paste(icon, battery_pos)
|
||
break
|
||
|
||
def _draw_statistics(self, image: Image.Image, draw: ImageDraw.Draw):
|
||
stats = [
|
||
(self.shared_data.target, (self.px(8), self.py(22)),
|
||
(self.px(28), self.py(22)), str(self.shared_data.target_count)),
|
||
(self.shared_data.port, (self.px(47), self.py(22)),
|
||
(self.px(67), self.py(22)), str(self.shared_data.port_count)),
|
||
(self.shared_data.vuln, (self.px(86), self.py(22)),
|
||
(self.px(106), self.py(22)), str(self.shared_data.vuln_count)),
|
||
(self.shared_data.cred, (self.px(8), self.py(41)),
|
||
(self.px(28), self.py(41)), str(self.shared_data.cred_count)),
|
||
(self.shared_data.money, (self.px(3), self.py(172)),
|
||
(self.px(3), self.py(192)), str(self.shared_data.coin_count)),
|
||
(self.shared_data.level, (self.px(2), self.py(217)),
|
||
(self.px(4), self.py(237)), str(self.shared_data.level_count)),
|
||
(self.shared_data.zombie, (self.px(47), self.py(41)),
|
||
(self.px(67), self.py(41)), str(self.shared_data.zombie_count)),
|
||
(self.shared_data.networkkb, (self.px(102), self.py(190)),
|
||
(self.px(102), self.py(208)), str(self.shared_data.network_kb_count)),
|
||
(self.shared_data.data, (self.px(86), self.py(41)),
|
||
(self.px(106), self.py(41)), str(self.shared_data.data_count)),
|
||
(self.shared_data.attacks, (self.px(100), self.py(218)),
|
||
(self.px(102), self.py(237)), str(self.shared_data.attacks_count)),
|
||
]
|
||
|
||
for img, img_pos, text_pos, text in stats:
|
||
if img is not None:
|
||
image.paste(img, img_pos)
|
||
draw.text(text_pos, text, font=self.shared_data.font_arial9, fill=0)
|
||
|
||
def _draw_status_text(self, draw: ImageDraw.Draw):
|
||
if self.show_ip_on_screen:
|
||
draw.text((self.px(35), self.py(60)), self.current_ip,
|
||
font=self.shared_data.font_arial9, fill=0)
|
||
draw.text((self.px(35), self.py(69)), self.shared_data.bjorn_status_text,
|
||
font=self.shared_data.font_arial9, fill=0)
|
||
draw.text((self.px(35), self.py(78)), self.shared_data.bjorn_status_text2,
|
||
font=self.shared_data.font_arial9, fill=0)
|
||
draw.text((self.px(102), self.py(78)), self.shared_data.bjorn_progress,
|
||
font=self.shared_data.font_arial9, fill=0)
|
||
draw.line((1, self.py(89), self.shared_data.width - 1, self.py(89)), fill=0)
|
||
else:
|
||
draw.text((self.px(35), self.py(65)), self.shared_data.bjorn_status_text,
|
||
font=self.shared_data.font_arial9, fill=0)
|
||
draw.text((self.px(35), self.py(75)), self.shared_data.bjorn_status_text2,
|
||
font=self.shared_data.font_arial9, fill=0)
|
||
draw.text((self.px(102), self.py(75)), self.shared_data.bjorn_progress,
|
||
font=self.shared_data.font_arial9, fill=0)
|
||
draw.line((1, self.py(87), self.shared_data.width - 1, self.py(87)), fill=0)
|
||
|
||
def _draw_decorations(self, image: Image.Image, draw: ImageDraw.Draw):
|
||
if self.show_ssid_on_screen:
|
||
draw.text((self.px(3), self.py(160)), self.ssid,
|
||
font=self.shared_data.font_arial9, fill=0)
|
||
draw.line((0, self.py(170), self.shared_data.width, self.py(170)), fill=0)
|
||
else:
|
||
frise_x, frise_y = self.get_frise_position()
|
||
if self.shared_data.frise is not None:
|
||
image.paste(self.shared_data.frise, (frise_x, frise_y))
|
||
|
||
draw.rectangle((0, 0, self.shared_data.width - 1, self.shared_data.height - 1), outline=0)
|
||
draw.line((0, self.py(20), self.shared_data.width, self.py(20)), fill=0)
|
||
draw.line((0, self.py(59), self.shared_data.width, self.py(59)), fill=0)
|
||
|
||
def _draw_comment_text(self, draw: ImageDraw.Draw):
|
||
# Cache key for the layout
|
||
key = (self.shared_data.bjorn_says, self.shared_data.width, id(self.shared_data.font_arialbold))
|
||
now = time.time()
|
||
if (
|
||
self._comment_layout_cache["key"] != key or
|
||
(now - self._comment_layout_cache["ts"]) >= self._comment_layout_min_interval
|
||
):
|
||
lines = self.shared_data.wrap_text(
|
||
self.shared_data.bjorn_says,
|
||
self.shared_data.font_arialbold,
|
||
self.shared_data.width - 4
|
||
)
|
||
self._comment_layout_cache = {"key": key, "lines": lines, "ts": now}
|
||
else:
|
||
lines = self._comment_layout_cache["lines"]
|
||
|
||
y_text = self.py(92)
|
||
font = self.shared_data.font_arialbold
|
||
bbox = font.getbbox('Aj')
|
||
font_height = (bbox[3] - bbox[1]) if bbox else font.size
|
||
|
||
for line in lines:
|
||
draw.text((self.px(4), y_text), line,
|
||
font=font, fill=0)
|
||
y_text += font_height + self.shared_data.line_spacing
|
||
|
||
def _save_screenshot(self, image: Image.Image):
|
||
try:
|
||
out_img = image
|
||
if self.web_screen_reversed:
|
||
out_img = out_img.transpose(Image.ROTATE_180)
|
||
|
||
screenshot_path = os.path.join(self.shared_data.web_dir, "screen.png")
|
||
with open(screenshot_path, 'wb') as img_file:
|
||
out_img.save(img_file)
|
||
img_file.flush()
|
||
os.fsync(img_file.fileno())
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error saving screenshot: {e}")
|
||
|
||
def _cleanup_display(self):
|
||
try:
|
||
if self.epd_enabled:
|
||
self.shared_data.epd.init_full_update()
|
||
blank_image = Image.new('1', (self.shared_data.width, self.shared_data.height), 255)
|
||
blank_image = self._pad_for_v2(blank_image)
|
||
self.shared_data.epd.display_partial(blank_image)
|
||
if self.shared_data.double_partial_refresh:
|
||
self.shared_data.epd.display_partial(blank_image)
|
||
logger.info("EPD display cleared and device exited")
|
||
try:
|
||
self.shared_data.epd.sleep()
|
||
except Exception:
|
||
pass
|
||
else:
|
||
logger.info("Display thread exited (EPD was disabled)")
|
||
except Exception as e:
|
||
logger.error(f"Error clearing display: {e}")
|
||
|
||
|
||
def handle_exit_display(signum, frame, display_thread=None):
|
||
"""Signal handler to cleanly exit display threads"""
|
||
shared_data.display_should_exit = True
|
||
logger.info(f"Exit signal {signum} received, shutting down display...")
|
||
|
||
try:
|
||
if display_thread:
|
||
display_thread.join(timeout=10.0)
|
||
if display_thread.is_alive():
|
||
logger.warning("Display thread did not exit cleanly")
|
||
else:
|
||
logger.info("Display thread finished cleanly.")
|
||
except Exception as e:
|
||
logger.error(f"Error while closing the display: {e}")
|
||
|
||
sys.exit(0) |