diff --git a/trmnl_server.py b/trmnl_server.py index d14c30f..e5fe189 100644 --- a/trmnl_server.py +++ b/trmnl_server.py @@ -44,7 +44,19 @@ def wrap_socket_and_handle(self, client_socket, address): for msg in ["CERTIFICATE_UNKNOWN", "UNEXPECTED_EOF_WHILE_READING"] ): # Suppress the annoying SSL handshake errors - client_socket.close() + try: + client_socket.close() + except: + pass + return + raise + except OSError as e: + # Handle file descriptor exhaustion gracefully + if e.errno == 24: # No file descriptors available + try: + client_socket.close() + except: + pass return raise @@ -61,6 +73,9 @@ def handle_error(self, socket, address): ): # Suppress the annoying SSL handshake errors return + if exctype is OSError and getattr(value, 'errno', None) == 24: + # Suppress file descriptor exhaustion errors in error handler + return super().handle_error(socket, address) @@ -106,6 +121,10 @@ def filter(self, record): logger.info("[Main] Starting trmnlServer") +# Create a requests session for connection pooling (FIX: reduces file descriptor usage) +http_session = requests.Session() + + ## helper def get_ip_address(): """ @@ -250,12 +269,16 @@ def persist_log(): """ Persist the logs to the log file and clear the in-memory logs. """ - with open(log_file, "a", encoding="utf-8") as log_file_handle: - for log in logs: - log_file_handle.write( - f"{log['timestamp']} -- [{log['context']}] -- {log['info']}\n" - ) - logs.clear() + try: + with open(log_file, "a", encoding="utf-8") as log_file_handle: + for log in logs: + log_file_handle.write( + f"{log['timestamp']} -- [{log['context']}] -- {log['info']}\n" + ) + logs.clear() + except OSError as e: + # FIX: Handle file descriptor exhaustion gracefully + logger.error(f"[persist_log] Failed to persist logs: {e}") def add_log_entry(log_context, info): @@ -302,16 +325,20 @@ def persist_client_data(): Persist the client data to the database file and clear the in-memory database, keeping only the last entry. """ - with open(db_file, "a", encoding="utf-8") as db_file_handle: - for entry in client_data_db: - db_file_handle.write( - f"{entry['timestamp']} -- bVolt: {entry['battery_voltage']}, " - f"rssi: {entry['rssi']}\n" - ) - if len(client_data_db) > 1: - last_entry = client_data_db.pop() - client_data_db.clear() - client_data_db.append(last_entry) + try: + with open(db_file, "a", encoding="utf-8") as db_file_handle: + for entry in client_data_db: + db_file_handle.write( + f"{entry['timestamp']} -- bVolt: {entry['battery_voltage']}, " + f"rssi: {entry['rssi']}\n" + ) + if len(client_data_db) > 1: + last_entry = client_data_db.pop() + client_data_db.clear() + client_data_db.append(last_entry) + except OSError as e: + # FIX: Handle file descriptor exhaustion gracefully + logger.error(f"[persist_client_data] Failed to persist client data: {e}") def add_client_log_entry(log_entry): @@ -333,13 +360,17 @@ def persist_client_log_data(): to the file. After writing, if there is more than one entry in the client_log_db, it retains only the last entry and clears the rest. """ - with open(db_client_log_file, "a", encoding="utf-8") as log_file_handle: - for entry in client_log_db: - log_file_handle.write(f"{entry}\n") - if len(client_log_db) > 1: - last_entry = client_log_db.pop() - client_log_db.clear() - client_log_db.append(last_entry) + try: + with open(db_client_log_file, "a", encoding="utf-8") as log_file_handle: + for entry in client_log_db: + log_file_handle.write(f"{entry}\n") + if len(client_log_db) > 1: + last_entry = client_log_db.pop() + client_log_db.clear() + client_log_db.append(last_entry) + except OSError as e: + # FIX: Handle file descriptor exhaustion gracefully + logger.error(f"[persist_client_log_data] Failed to persist client log: {e}") def reading_client_data(): @@ -348,19 +379,22 @@ def reading_client_data(): """ client_data_db_read = [] if os.path.exists(db_file): - with open(db_file, "r", encoding="utf-8") as db_file_handle: - lines = db_file_handle.readlines() - for line in lines: - data = line.split(" -- ") - battery_voltage = float(data[1].split(",")[0].split(": ")[1]) - rssi = int(data[1].split(",")[1].split(": ")[1]) - timestamp = data[0] - entry = { - "battery_voltage": battery_voltage, - "rssi": rssi, - "timestamp": timestamp, - } - client_data_db_read.append(entry) + try: + with open(db_file, "r", encoding="utf-8") as db_file_handle: + lines = db_file_handle.readlines() + for line in lines: + data = line.split(" -- ") + battery_voltage = float(data[1].split(",")[0].split(": ")[1]) + rssi = int(data[1].split(",")[1].split(": ")[1]) + timestamp = data[0] + entry = { + "battery_voltage": battery_voltage, + "rssi": rssi, + "timestamp": timestamp, + } + client_data_db_read.append(entry) + except (OSError, IndexError, ValueError) as e: + logger.error(f"[reading_client_data] Failed to read client data: {e}") # combine client_data_db and client_data_db_read only if more than 1 entry in client_data_db if len(client_data_db) > 1: client_data_db_read.extend(client_data_db) @@ -392,189 +426,194 @@ def get_battery_icon(battery): def add_footer_to_image(src_image, wifi_percentage, battery_percentage): """ Adds a footer to an image with WiFi and battery percentages, and the current date and time. + FIX: Now properly closes PIL Image objects to prevent file descriptor leaks. """ - # Load the source image - img = Image.open(BytesIO(src_image.getvalue())) - # Resize the source image to make space for the footer - img = img.crop((0, 0, img.width, img.height - FOOTER_HEIGHT)) - # Create a new image with extra space for the footer - new_img = Image.new( - "1", (img.width, img.height + FOOTER_HEIGHT), color=BACKGROUND_TYPE - ) - # Paste the original image onto the new image - new_img.paste(img, (0, 0)) - # Initialize ImageDraw - d = ImageDraw.Draw(new_img) - logger.debug("[image modification] adding footer to image") - # Load fonts - # Load icon font (FontAwesome) - icon_font_path = os.path.join(base_path, "web", "fontawesome-webfont.ttf") - icon_font = None + img = None + new_img = None try: - icon_font = ImageFont.truetype(icon_font_path, 24) - logger.debug("[image modification] loaded FontAwesome from %s", icon_font_path) - except Exception as e: - logger.warning("[image modification] could not load FontAwesome: %s", str(e)) - icon_font = ImageFont.load_default() - - # Load text font - try multiple options for cross-platform support - text_font = None - font_candidates = [ - "arialbd.ttf", # Windows - "arial.ttf", # Windows - "/usr/share/fonts/ttf-dejavu/DejaVuSans-Bold.ttf", # Alpine Linux - "/usr/share/fonts/ttf-dejavu/DejaVuSans.ttf", # Alpine Linux - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Debian/Ubuntu - ] - - for font_path in font_candidates: + # Load the source image + img = Image.open(BytesIO(src_image.getvalue())) + # Resize the source image to make space for the footer + img = img.crop((0, 0, img.width, img.height - FOOTER_HEIGHT)) + # Create a new image with extra space for the footer + new_img = Image.new( + "1", (img.width, img.height + FOOTER_HEIGHT), color=BACKGROUND_TYPE + ) + # Paste the original image onto the new image + new_img.paste(img, (0, 0)) + # Initialize ImageDraw + d = ImageDraw.Draw(new_img) + logger.debug("[image modification] adding footer to image") + # Load fonts + # Load icon font (FontAwesome) + icon_font_path = os.path.join(base_path, "web", "fontawesome-webfont.ttf") + icon_font = None try: - text_font = ImageFont.truetype(font_path, 14) - logger.debug("[image modification] loaded text font: %s", font_path) - break - except: - continue - - if text_font is None: - logger.warning("[image modification] no system fonts available, using default") - text_font = ImageFont.load_default() + icon_font = ImageFont.truetype(icon_font_path, 24) + logger.debug("[image modification] loaded FontAwesome from %s", icon_font_path) + except Exception as e: + logger.warning("[image modification] could not load FontAwesome: %s", str(e)) + icon_font = ImageFont.load_default() + + # Load text font - try multiple options for cross-platform support + text_font = None + font_candidates = [ + "arialbd.ttf", # Windows + "arial.ttf", # Windows + "/usr/share/fonts/ttf-dejavu/DejaVuSans-Bold.ttf", # Alpine Linux + "/usr/share/fonts/ttf-dejavu/DejaVuSans.ttf", # Alpine Linux + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Debian/Ubuntu + ] - fonts = { - "icon_font": icon_font, - "text_font": text_font, - } + for font_path in font_candidates: + try: + text_font = ImageFont.truetype(font_path, 14) + logger.debug("[image modification] loaded text font: %s", font_path) + break + except: + continue + + if text_font is None: + logger.warning("[image modification] no system fonts available, using default") + text_font = ImageFont.load_default() + + fonts = { + "icon_font": icon_font, + "text_font": text_font, + } - # Define positions - positions = { - "text_line_height": 7, - "symbol_line_height": 4, - "wifi_icon_position": (18, img.height + 4), - "wifi_text_position": (50, img.height + 7), - "battery_icon_position": (104, img.height + 4), - "battery_text_position": (140, img.height + 7), - } + # Define positions + positions = { + "text_line_height": 7, + "symbol_line_height": 4, + "wifi_icon_position": (18, img.height + 4), + "wifi_text_position": (50, img.height + 7), + "battery_icon_position": (104, img.height + 4), + "battery_text_position": (140, img.height + 7), + } - # Draw line if background is white - if BACKGROUND_TYPE == 1: - d.line([(0, img.height + 1), (img.width, img.height + 1)], fill=0, width=2) - else: - width_left_side = 142 if battery_percentage != 255 else 100 - # Draw white rounded rectangles for left and right sides - d.rounded_rectangle( - [ - -10, - img.height + 3, - positions["wifi_text_position"][0] + width_left_side, - img.height + FOOTER_HEIGHT + 5, - ], - fill=1, - radius=5, - ) - # Right side pill for date/time - will be calculated after text width is known - # d.rounded_rectangle( - # [ - # positions["date_time_position"][0] - 8, - # img.height + 3, - # 810, - # img.height + FOOTER_HEIGHT + 5, - # ], - # fill=1, - # radius=5, - # ) - - # Draw WiFi icon \uf1eb and percentage - d.text( - positions["wifi_icon_position"], - "\uf1eb", - fill=BACKGROUND_TYPE * -1, - font=fonts["icon_font"], - ) - d.text( - positions["wifi_text_position"], - f"{round(wifi_percentage)} %", - fill=BACKGROUND_TYPE * -1, - font=fonts["text_font"], - ) + # Draw line if background is white + if BACKGROUND_TYPE == 1: + d.line([(0, img.height + 1), (img.width, img.height + 1)], fill=0, width=2) + else: + width_left_side = 142 if battery_percentage != 255 else 100 + # Draw white rounded rectangles for left and right sides + d.rounded_rectangle( + [ + -10, + img.height + 3, + positions["wifi_text_position"][0] + width_left_side, + img.height + FOOTER_HEIGHT + 5, + ], + fill=1, + radius=5, + ) - # Draw battery icon and percentage - if battery_percentage == 255: - d.text( - positions["battery_icon_position"], - "\uf244", - fill=BACKGROUND_TYPE * -1, - font=fonts["icon_font"], - ) - d.text( - ( - positions["battery_icon_position"][0] + 10, - positions["battery_icon_position"][1], - ), - "\uf0e7", - fill=BACKGROUND_TYPE * -1, - font=fonts["icon_font"], - ) - else: + # Draw WiFi icon \uf1eb and percentage d.text( - positions["battery_icon_position"], - get_battery_icon(battery_percentage), + positions["wifi_icon_position"], + "\uf1eb", fill=BACKGROUND_TYPE * -1, font=fonts["icon_font"], ) d.text( - positions["battery_text_position"], - f"{round(battery_percentage)} %", + positions["wifi_text_position"], + f"{round(wifi_percentage)} %", fill=BACKGROUND_TYPE * -1, font=fonts["text_font"], ) - # Get the current time in the configured time zone - time_zone = pytz.timezone(config_manager.config["time_zone"]) - date_time = datetime.datetime.now(time_zone).strftime("%d.%m.%Y %H:%M") + # Draw battery icon and percentage + if battery_percentage == 255: + d.text( + positions["battery_icon_position"], + "\uf244", + fill=BACKGROUND_TYPE * -1, + font=fonts["icon_font"], + ) + d.text( + ( + positions["battery_icon_position"][0] + 10, + positions["battery_icon_position"][1], + ), + "\uf0e7", + fill=BACKGROUND_TYPE * -1, + font=fonts["icon_font"], + ) + else: + d.text( + positions["battery_icon_position"], + get_battery_icon(battery_percentage), + fill=BACKGROUND_TYPE * -1, + font=fonts["icon_font"], + ) + d.text( + positions["battery_text_position"], + f"{round(battery_percentage)} %", + fill=BACKGROUND_TYPE * -1, + font=fonts["text_font"], + ) - # Calculate text width for right alignment - try: - bbox = d.textbbox((0, 0), date_time, font=fonts["text_font"]) - text_width = bbox[2] - bbox[0] - except: - # Fallback for older PIL versions - text_width = len(date_time) * 8 # Rough estimate - - # Right-align with 10px margin from right edge - date_time_x = img.width - text_width - 10 - date_time_y = img.height + 7 - - # Draw the right-side pill background (only if BACKGROUND_TYPE is black) - if BACKGROUND_TYPE == 0: - d.rounded_rectangle( - [ - date_time_x - 8, - img.height + 3, - img.width + 10, - img.height + FOOTER_HEIGHT + 5, - ], - fill=1, - radius=5, - ) + # Get the current time in the configured time zone + time_zone = pytz.timezone(config_manager.config["time_zone"]) + date_time = datetime.datetime.now(time_zone).strftime("%d.%m.%Y %H:%M") - d.text( - (date_time_x, date_time_y), - date_time, - fill=BACKGROUND_TYPE * -1, - font=fonts["text_font"], - ) + # Calculate text width for right alignment + try: + bbox = d.textbbox((0, 0), date_time, font=fonts["text_font"]) + text_width = bbox[2] - bbox[0] + except: + # Fallback for older PIL versions + text_width = len(date_time) * 8 # Rough estimate + + # Right-align with 10px margin from right edge + date_time_x = img.width - text_width - 10 + date_time_y = img.height + 7 + + # Draw the right-side pill background (only if BACKGROUND_TYPE is black) + if BACKGROUND_TYPE == 0: + d.rounded_rectangle( + [ + date_time_x - 8, + img.height + 3, + img.width + 10, + img.height + FOOTER_HEIGHT + 5, + ], + fill=1, + radius=5, + ) - # Save the new image to a BytesIO object - img_io = BytesIO() - new_img.save(img_io, format="BMP") - img_io.seek(0) + d.text( + (date_time_x, date_time_y), + date_time, + fill=BACKGROUND_TYPE * -1, + font=fonts["text_font"], + ) + + # Save the new image to a BytesIO object + img_io = BytesIO() + new_img.save(img_io, format="BMP") + img_io.seek(0) - # Manually adjust the BMP header - img_io.seek(54) - img_io.write(bytes([0, 0, 0, 0, 255, 255, 255, 0])) - img_io.seek(0) + # Manually adjust the BMP header + img_io.seek(54) + img_io.write(bytes([0, 0, 0, 0, 255, 255, 255, 0])) + img_io.seek(0) - return img_io + return img_io + finally: + # FIX: Explicitly close PIL Image objects to release file descriptors + if img is not None: + try: + img.close() + except: + pass + if new_img is not None: + try: + new_img.close() + except: + pass def get_and_modify_image(image_blob): @@ -593,37 +632,47 @@ def get_no_image(): Create a blank image with a white background and overlay text indicating no image is available, along with the current date and time. The image is saved in BMP format and returned as a BytesIO object. + FIX: Now properly closes PIL Image object. """ - # Create a blank image with white background - img = Image.new( - "1", (800, 480), color=1 - ) # '1' mode for 1-bit pixels, black and white - - # Initialize ImageDraw - d = ImageDraw.Draw(img) - - # Load font + img = None try: - text_font = ImageFont.truetype("DejaVuSans.ttf", 24) - except IOError: - text_font = ImageFont.load_default() - - # Define text position and content - text = "No image available" - date_time = datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S") - text = f"{text}\n{date_time}" - text_bbox = d.textbbox((0, 0), text, font=text_font) - text_width, text_height = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1] - text_position = ((img.width - text_width) // 2, (img.height - text_height) // 2) + # Create a blank image with white background + img = Image.new( + "1", (800, 480), color=1 + ) # '1' mode for 1-bit pixels, black and white - # Draw text on the image - d.text(text_position, text, fill=0, font=text_font) # fill=0 for black + # Initialize ImageDraw + d = ImageDraw.Draw(img) - # Save the image to a BytesIO object - img_io = BytesIO() - img.save(img_io, format="BMP") - img_io.seek(0) - return img_io + # Load font + try: + text_font = ImageFont.truetype("DejaVuSans.ttf", 24) + except IOError: + text_font = ImageFont.load_default() + + # Define text position and content + text = "No image available" + date_time = datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S") + text = f"{text}\n{date_time}" + text_bbox = d.textbbox((0, 0), text, font=text_font) + text_width, text_height = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1] + text_position = ((img.width - text_width) // 2, (img.height - text_height) // 2) + + # Draw text on the image + d.text(text_position, text, fill=0, font=text_font) # fill=0 for black + + # Save the image to a BytesIO object + img_io = BytesIO() + img.save(img_io, format="BMP") + img_io.seek(0) + return img_io + finally: + # FIX: Close the image to release resources + if img is not None: + try: + img.close() + except: + pass # calculate battery state @@ -668,9 +717,10 @@ def get_wifi_signal_strength(rssi): def load_image(image_path): """ Load an image from a local file path or a URL. + FIX: Uses session for HTTP requests to enable connection pooling. """ if image_path.startswith("http://") or image_path.startswith("https://"): - response = requests.get(image_path, timeout=10) + response = http_session.get(image_path, timeout=10) response.raise_for_status() # Raise an exception for HTTP errors return BytesIO(response.content) with open(image_path, "rb") as image_file: @@ -760,7 +810,10 @@ def test_adapted_image(): 1. Generates the adapted image by calling `get_and_modify_image()`. 2. Logs the request with a timestamp and the client's IP address. 3. Returns the adapted image as a BMP file. + FIX: Reset BytesIO position before reading. """ + # FIX: Seek to beginning before reading + global_state["image"]["current_send_image"].seek(0) # Generate the adapted image global_state["image"]["current_send_image"] = get_and_modify_image( BytesIO(global_state["image"]["current_send_image"].read()) @@ -945,12 +998,31 @@ def api_log(): This function processes a JSON request containing log entries, adds each log entry to the client log, and prints it. Additionally, it logs the request with a timestamp and context. + + FIX: Properly handles different JSON structures from client. """ content = request.json - log_data = content.get("log") - if log_data: - logs_array = log_data.get("logs_array") - if logs_array: + + # FIX: Handle different JSON structures the client might send + # Some clients send {"log": {"logs_array": [...]}} + # Some clients send {"logs": [...]} + logs_array = None + + if content: + # Try the expected structure first + log_data = content.get("log") + if log_data and isinstance(log_data, dict): + logs_array = log_data.get("logs_array") + + # Try alternative structure: {"logs": [...]} + if logs_array is None: + logs_array = content.get("logs") + + # Try alternative structure: {"log": {"logs": [...]}} + if logs_array is None and log_data and isinstance(log_data, dict): + logs_array = log_data.get("logs") + + if logs_array and isinstance(logs_array, list): for log_entry in logs_array: add_client_log_entry(log_entry) print(log_entry) @@ -960,6 +1032,44 @@ def api_log(): return jsonify({"status": "logged"}), 200 +@app.route("/api/regenerate", methods=["POST"]) +def api_regenerate(): + """ + Handle the /api/regenerate endpoint to reload the static image. + """ + add_log_entry( + "Request received at /api/regenerate", + f"from IP: {request.remote_addr}", + ) + + try: + # Reload the image from the configured path + global_state["image"]["current_orig_image"] = load_image( + config_manager.config["image_path"] + ) + + if config_manager.config["image_modification"]: + global_state["image"]["current_send_image"] = get_and_modify_image( + global_state["image"]["current_orig_image"] + ) + else: + global_state["image"]["current_send_image"] = global_state["image"][ + "current_orig_image" + ] + + return jsonify({ + "status": "success", + "message": "Static image reloaded", + "source": "static" + }), 200 + except Exception as e: + logger.error(f"[api_regenerate] Failed to regenerate image: {e}") + return jsonify({ + "status": "error", + "message": str(e) + }), 500 + + @app.route("/settings", methods=["GET"]) def get_settings(): """ @@ -1235,6 +1345,8 @@ def handle_exit(signum, frame): persist_log() persist_client_data() persist_client_log_data() + # FIX: Close the HTTP session to release connections + http_session.close() print("Data persisted. Exiting...") sys.exit(0) @@ -1274,26 +1386,6 @@ def handle(self): raise -# if __name__ == '__main__': -# # Start the server -# WSGIRequestHandler = SSLRequestHandler -# global_state['image']['bmp_send_switch'] = True -# # Generate a self-signed certificate and key -# cert_file = os.path.join(current_dir, 'ssl/cert.pem') -# key_file = os.path.join(current_dir, 'ssl/key.pem') - -# if not os.path.exists(cert_file) or not key_file: -# os.system( -# f'openssl req -x509 -newkey rsa:4096 -keyout {key_file} -out {cert_file} ' -# f'-days 1 -nodes -subj "/CN=localhost"' -# ) - -# # Run HTTPS server on port SERVER_PORT -# context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -# context.load_cert_chain(certfile=cert_file, keyfile=key_file) -# app.run(host='0.0.0.0', port=SERVER_PORT, ssl_context=context, debug=False) - - def generate_self_signed_cert(cert_file, key_file, server_ip): """ Generate a self-signed certificate and key using the cryptography library.