Третья (и пока что заключительная) часть об общественно-полезных DIY-проектах в посёлке (часть 1, часть 2). Расскажу про светодиодные экраны и игровой автомат для детской площадки.

Однажды выяснилось, что мы должны на въезде в поселок разместить информационный стенд, на котором можно было бы прочитать, кто обслуживает поселок, контактные телефоны и тому подобное. Уверен, многие видели такие стенды и в посёлках и в многоквартирных домах.

Типичный информационный стенд. Картинка из интернета.
Типичный информационный стенд. Картинка из интернета.

Выглядят стенды в большинстве своем ужасно, пользоваться ими неудобно (а фактически никто и не пользуется), поэтому возникла идея выполнить формальное требование, но в виде экрана, на котором отображалась бы полезная информация.

Первая мысль была: добыть какой-нибудь большой телевизор и повесить его под навесом. Но выяснилось, что модели, защищенные от влаги, стоят очень дорого, на солнце их видно плохо, а разрешение у них сильно избыточно.

Вначале попробовали сделать экран из адресной светодиодной ленты, но быстро стало понятно, что для реального применения он не подходит: для просмотра в солнечную погоду все пространство между светодиодами должно быть черным, а значит, нужно делать накладку с отверстиями под каждый светодиод. К тому же, разрешение получалось очень низким, а значит, экран должен иметь большие размеры чтобы на него влезли хотя бы несколько слов текста. Столько свободного места у нас не было.

Первые прототипы экранов
Первые прототипы экранов

Но на улицах же часто встречаются рекламные конструкции в виде экранов, как-то их изготавливают, а значит, сможем изготовить и мы. Выяснилось, что экраны, даже самые большие, собираются из относительно небольших модулей. Модули эти бывают обычные и уличные, именно таких 4 штуки и были добыты на алиэкспрессе.

Встал вопрос: что может выступать источником для отображения видео на этих модулях? Оказалось, с этим вполне справляются даже микроконтроллеры типа ESP32.

Что работает на одном светодиодном модуле, заработает и на нескольких. Модули объединяются в цепочки при помощи шлейфов и получается экран произвольного размера. Слишком длинные цепочки будут медленно обновляться, тогда экран делят на несколько независимых цепочек и делят картинку между ними на уровне контроллера (как будто подключают несколько отдельных экранов).

Пример подключения большого экрана из 4-х змеек по 8 модулей. Картинка из интернета
Пример подключения большого экрана из 4-х змеек по 8 модулей. Картинка из интернета

В принципе, на этом уже можно было остановиться: загрузить в контроллер картинки и он бы их показывал по кругу. Но захотелось большего: подключить экран к локальной сети по проводу (на улице wi-fi работает плохо), обновлять удаленно отображаемые картинки, и самое интересное: в реальном времени выводить нужные изображения, реагируя на события. Приехала к шлагбауму машина -- распознав номер, можно понять, из какого она дома и показать ей какое-нибудь персональное сообщение.

Была задействована имеющаяся raspberry pi 3b+: для нее нашлась отличная готовая библиотека для работы со светодиодными модулями. RPi подключается к экрану в соответствии с инструкцией, далее методом научного тыка были подобраны параметры, при которых картинка отображается корректно, и написана небольшая программа для отображения картинок и текстовых сообщений. Из неочевидного: была реализована регулировка яркости в соответствии с временем суток, для этого вычисляется время восхода и заката.

Код программы
import time
import threading
import math
import os
import logging
from rgbmatrix import RGBMatrix, RGBMatrixOptions
from PIL import Image, ImageDraw, ImageFont
from datetime import datetime, timedelta
import pytz
import sys
import json

try:
    from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
    from urlparse import urlparse, parse_qs
    import SocketServer
except ImportError:
    from http.server import BaseHTTPRequestHandler, HTTPServer
    from urllib.parse import urlparse, parse_qs
    import socketserver as SocketServer

def get_brightness():
    # Use pytz for timezone handling in Python 2.7
    moscow_tz = pytz.timezone('Europe/Moscow')
    now = datetime.now(moscow_tz)

    # Get day of year
    day_of_year = now.timetuple().tm_yday
    
    # Approximate calculation for Moscow (latitude ~55.75)
    # This is a simplified calculation - for production consider using a proper library
    
    # Solar declination angle (simplified)
    declination = 23.45 * math.sin(math.radians(360.0/365.0 * (day_of_year - 81)))
    
    # Hour angle for sunrise/sunset
    lat_rad = math.radians(55.212300)  # Moscow latitude
    
    # Sunset hour angle
    sunset_hour_angle = math.degrees(math.acos(-math.tan(lat_rad) * math.tan(math.radians(declination))))
    
    # Sunrise and sunset in hours from solar noon
    sunrise_hours = 12.0 - sunset_hour_angle/15.0
    sunset_hours = 12.0 + sunset_hour_angle/15.0
    
    # Apply equation of time correction (simplified)
    B = math.radians(360.0/365.0 * (day_of_year - 81))
    equation_of_time = 9.87 * math.sin(2*B) - 7.53 * math.cos(B) - 1.5 * math.sin(B)
    
    sunrise_hours -= equation_of_time/60.0
    sunset_hours -= equation_of_time/60.0
    
    # Create datetime objects for sunrise and sunset
    sunrise_time = now.replace(hour=int(sunrise_hours), 
                               minute=int((sunrise_hours % 1) * 60),
                               second=0, microsecond=0) + timedelta(minutes=30)
    sunset_time = now.replace(hour=int(sunset_hours), 
                              minute=int((sunset_hours % 1) * 60),
                              second=0, microsecond=0) + timedelta(minutes=30)
    # Calculate transition periods
    sunrise_start = sunrise_time - timedelta(minutes=30)
    sunrise_end = sunrise_time + timedelta(minutes=30)
    sunset_start = sunset_time - timedelta(minutes=30)
    sunset_end = sunset_time + timedelta(minutes=30)
    
    # Determine the current period
    if sunrise_start <= now <= sunrise_end:
        return 60
    elif sunset_start <= now <= sunset_end:
        return 60
    elif sunrise_end <= now <= sunset_start:
        return 100
    else:
        return 30

# Конфигурация логгирования
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Конфигурация RGB матрицы
options = RGBMatrixOptions()
options.rows = 32
options.cols = 64
options.chain_length = 2
options.parallel = 2
options.hardware_mapping = 'regular'
options.multiplexing = 1
options.gpio_slowdown = 2
options.scan_mode = 1
options.pwm_lsb_nanoseconds = 600
options.show_refresh_rate = False
options.brightness = get_brightness()

matrix = RGBMatrix(options=options)

# Загрузка шрифтов - ОБЪЯВЛЯЕМ ГЛОБАЛЬНО
try:
    FONT = ImageFont.truetype("/home/pi/DejaVuSans.ttf", 12)
    FONT_LARGE = ImageFont.truetype("/home/pi/DejaVuSans.ttf", 18)
    FONT_XL = ImageFont.truetype("/home/pi/DejaVuSans.ttf", 25)
except:
    # Используем стандартный шрифт если не найден
    FONT = ImageFont.load_default()
    FONT_LARGE = ImageFont.load_default()
    FONT_XL = ImageFont.load_default()

# Управление дисплеем
class DisplayState:
    NORMAL = 0
    SHOW_DEBT = 1
    SHOW_VOTE = 2
    SHOW_175 = 3

class DisplayControl:
    def __init__(self):
        self.state = DisplayState.NORMAL
        self.debt_end_time = 0
        self.message_end_time = 0
        self.debt_duration = 10
        self.message_duration = 10
        self.last_update = time.time()
        self.current_image = None
        self.next_change_time = 0
        self.current_image_index = 0
        self.image_display_duration = 15
        self.images = []
        self.default_debt_duration = 5

display = DisplayControl()

def load_image(path):
    """Загрузка и изменение размера изображения."""
    try:
        image = Image.open(path)
        if image.mode != 'RGB':
            image = image.convert('RGB')
        return image.resize((matrix.width, matrix.height))
    except Exception as e:
        logger.error("Error loading image %s: %s", path, str(e))
        # Создаем изображение с ошибкой
        image = Image.new("RGB", (matrix.width, matrix.height), "red")
        draw = ImageDraw.Draw(image)
        draw.text((10, 10), os.path.basename(path), font=FONT, fill="white")
        return image

def draw_text(xy, text, color="white", bg_color="black"):
    """Создание изображения с текстом."""
    image = Image.new("RGB", (matrix.width, matrix.height), bg_color)
    draw = ImageDraw.Draw(image)
    
# Получаем размер текста
    #text = u'Оплатите\n   долги'
    text_width, text_height = FONT.getsize(text)
    draw.text(xy, text, font=FONT, fill=color)
    return image

def draw_debt_screen(remaining_time):
    """Создание экрана с долгом."""
    image = Image.new("RGB", (matrix.width, matrix.height), "black")
    draw = ImageDraw.Draw(image)
    
    center_x, center_y = 30, matrix.height // 2
    radius = 20
    
    # Фоновый круг
    draw.ellipse([(center_x - radius, center_y - radius),
                  (center_x + radius, center_y + radius)], outline=(0, 0, 0))
    
    # Прогресс
    progress = 360 * (1.02 - remaining_time / display.debt_duration)
    
    # Отрисовка прогресса
    for angle in range(int(progress), 360, 1):
        start_x = center_x + (radius-4) * math.cos(math.radians(angle - 90))
        start_y = center_y + (radius-4) * math.sin(math.radians(angle - 90))
        end_x = center_x + (radius+4) * math.cos(math.radians(angle - 90))
        end_y = center_y + (radius+4) * math.sin(math.radians(angle - 90))
        draw.line([(start_x, start_y), (end_x, end_y)], fill=(255, 0, 0), width=2)
    
    # Текст счетчика
    countdown_text = str(int(remaining_time) + 1)
    
    if hasattr(FONT_LARGE, 'getsize'):
        text_width, text_height = FONT_LARGE.getsize(countdown_text)
    else:
        text_width, text_height = draw.textsize(countdown_text, font=FONT_LARGE)
    
    draw.text((center_x - text_width // 2, center_y - text_height // 2 - 3),
              countdown_text, font=FONT_LARGE, fill=(255, 201, 135))
    
    # Текст "Оплатите долги"
    debt_text = u'Оплатите\n   долги'
    draw.text((60, 15), debt_text, font=FONT, fill=(255, 201, 135))
    
    return image

def draw_speed_limit(speed_limit):
    """Создание экрана с ограничением скорости."""
    image = Image.new("RGB", (matrix.width, matrix.height), "black")
    draw = ImageDraw.Draw(image)
    
    center_x, center_y = 26, matrix.height // 2
    radius = 20
    
    # Красный круг
    for angle in range(0, 360, 1):
        start_x = center_x + (radius-2) * math.cos(math.radians(angle - 90))
        start_y = center_y + (radius-2) * math.sin(math.radians(angle - 90))
        end_x = center_x + (radius+2) * math.cos(math.radians(angle - 90))
        end_y = center_y + (radius+2) * math.sin(math.radians(angle - 90))
        draw.line([(start_x, start_y), (end_x, end_y)], fill=(255, 0, 0), width=2)
    
    # Ограничение скорости
    speed_text = str(speed_limit)
    
    if hasattr(FONT_XL, 'getsize'):
        text_width, text_height = FONT_XL.getsize(speed_text)
    else:
        text_width, text_height = draw.textsize(speed_text, font=FONT_XL)
    
    draw.text((center_x - text_width // 2 + 1, center_y - text_height // 2 - 3),
              speed_text, font=FONT_XL, fill=(255, 201, 135))
    
    # Предупреждающий текст
    lines = [u'Внимание!', u'На дорогах', u'дети']
    current_h = 7
    
    for line in lines:
        text_width, text_height = FONT.getsize(line)
        draw.text((58 + (65 - text_width) / 2, current_h), line, font=FONT, fill=(255, 201, 135))
        current_h += 17
    
    return image

def fade_between_images(img1, img2, steps=10, delay=0.05):
    """Плавный переход между изображениями."""
    for step in range(steps + 1):
        alpha = step / float(steps)
        blended = Image.blend(img1, img2, alpha)
        matrix.SetImage(blended)
        time.sleep(delay)

def update_display():
    """Обновление дисплея."""
    now = time.time()
    
    # Инициализация при первом запуске
    if not hasattr(update_display, 'initialized'):
        display.images = []
        # Добавляем изображения
        display.images.append(('image', load_image("/home/pi/rpi-fb-matrix/rpi-rgb-led-matrix/bindings/python/samples/logo1.gif"), 10))
        display.images.append(('image', draw_speed_limit(20), 30))
        display.images.append(('image', load_image("/home/pi/rpi-fb-matrix/rpi-rgb-led-matrix/bindings/python/samples/logo2.gif"), 10))
        display.images.append(('image', draw_speed_limit(20), 30))
        display.images.append(('image', draw_text((5, 7), u'По всем вопросам:\n+7(111)110-11-11\n (с 9 до 18, вт-сб)', color=(255, 201, 135), bg_color="black"), 5))
        display.images.append(('image', draw_text((20, 7), u'   Наш сайт:\n  poselok.ru\n поселок.рф', color=(255, 201, 135), bg_color="black"), 5))
        
        display.current_image = display.images[0][1]
        matrix.SetImage(display.current_image)
        display.next_change_time = now + display.images[0][2]
        update_display.initialized = True
    
    # Режим показа долга
    if display.state == DisplayState.SHOW_DEBT:
        remaining_time = display.debt_end_time - now
        if remaining_time > 0:
            display.current_image = draw_debt_screen(remaining_time)
            matrix.SetImage(display.current_image)
            return
        else:
            display.state = DisplayState.NORMAL
            display.current_image_index = 0
            last_image = display.current_image
            display.current_image = display.images[0][1]
            display.next_change_time = now + display.images[0][2]
            fade_between_images(last_image, display.current_image)
            return
    
    # Режим показа долга
    if display.state == DisplayState.SHOW_VOTE:
        remaining_time = display.message_end_time - now
        if remaining_time > 0:
            display.current_image = draw_vote_screen(remaining_time)
            matrix.SetImage(display.current_image)
            return
        else:
            display.state = DisplayState.NORMAL
            display.current_image_index = 0
            last_image = display.current_image
            display.current_image = display.images[0][1]
            display.next_change_time = now + display.images[0][2]
            fade_between_images(last_image, display.current_image)
            return

      # Режим 175
    if display.state == DisplayState.SHOW_175:
        remaining_time = display.message_end_time - now
        if remaining_time > 0:
            return
        else:
            display.state = DisplayState.NORMAL
            display.current_image_index = 0
            last_image = display.current_image
            display.current_image = display.images[0][1]
            display.next_change_time = now + display.images[0][2]
            fade_between_images(last_image, display.current_image)
            return
    
    # Нормальная смена изображений
    if now >= display.next_change_time:
        matrix.brightness = get_brightness()
        
        display.current_image_index = (display.current_image_index + 1) % len(display.images)
        last_image = display.current_image
        display.current_image = display.images[display.current_image_index][1]
        display.next_change_time = now + display.images[display.current_image_index][2]
        fade_between_images(last_image, display.current_image)

def display_loop():
    """Основной цикл дисплея."""
    while True:
        update_display()
        time.sleep(0.1)

# HTTP обработчик
class RequestHandler(BaseHTTPRequestHandler):
    def _set_headers(self, content_type='application/json'):
        self.send_response(200)
        self.send_header('Content-type', content_type)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
    
    def do_GET(self):
        parsed_path = urlparse(self.path)
        query = parse_qs(parsed_path.query)
        
        if parsed_path.path == '/showPayDebt':
            duration = 0
            try:
                if 'duration' in query:
                    duration = int(query['duration'][0])
            except:
                duration = display.default_debt_duration
            
            if duration <= 0:
                duration = display.default_debt_duration
            
            logger.info("Received showPayDebt request with duration %d seconds", duration)
            display.state = DisplayState.SHOW_DEBT
            display.debt_duration = duration
            display.debt_end_time = time.time() + duration
            
            response = {
                "status": "success",
                "message": "Pay debt message showing for %d seconds" % duration,
                "duration": duration
            }
            
            self._set_headers()
            self.wfile.write(json.dumps(response))
            
        elif parsed_path.path == '/showVote':
            duration = 0
            try:
                if 'duration' in query:
                    duration = int(query['duration'][0])
            except:
                duration = 5
            
            if duration <= 0:
                duration = 5
            
            logger.info("Received showVote request with duration %d seconds", duration)
            display.state = DisplayState.SHOW_VOTE
            display.message_duration = duration
            display.message_end_time = time.time() + duration
            
            response = {
                "status": "success",
                "message": "Vote message showing for %d seconds" % duration,
                "duration": duration
            }
            
            self._set_headers()
            self.wfile.write(json.dumps(response))
            
        elif parsed_path.path == '/health':
            response = {
                "status": "healthy",
                "current_image": display.current_image_index if hasattr(display, 'current_image_index') else -1,
                "total_images": len(display.images) if hasattr(display, 'images') else 0
            }
            
            self._set_headers()
            self.wfile.write(json.dumps(response))
            
        else:
            self.send_response(404)
            self.end_headers()
            self.wfile.write("Not Found")
    
    def do_POST(self):
        self.do_GET()
    
    def log_message(self, format, *args):
        logger.info("%s - %s" % (self.address_string(), format % args))

def run_server(port=5000):
    """Запуск HTTP сервера."""
    server_address = ('', port)
    httpd = HTTPServer(server_address, RequestHandler)
    logger.info('Starting HTTP server on port %d...', port)
    httpd.serve_forever()

if __name__ == "__main__":
    # Запускаем поток дисплея
    display_thread = threading.Thread(target=display_loop)
    display_thread.daemon = True
    display_thread.start()
    
    # Запускаем HTTP сервер
    try:
        run_server(5000)
    except KeyboardInterrupt:
        logger.info("Shutting down server...")
        matrix.Clear()

Корпус сделан очень просто: фанера, на которой закреплены LED модули, вставлена внутрь рамки из деревянных досок, собрано все на саморезы и покрашено в черный матовый.

Чем проще графика, тем лучше она смотрится на таком экране. Черный фон очень желателен.
Чем проще графика, тем лучше она смотрится на таком экране. Черный фон очень желателен.

Игровой автомат

Второй экран решили поставить на местной площади, рядом с детской площадкой. Цель та же: отображать различные объявления и тому подобное. И тут вспомнилась старая моя идея: сделать уличный игровой автомат, с которым могли бы взаимодействовать все желающие. Сначала хотел делать нажимаемые ногами кнопки, но в итоге остановился на такой механике: при помощи ультразвукового датчика замерять расстояние до игрока и далее он будет подходить и отходить, а по экрану будет двигаться персонаж. Получается kinect на минималках.

Начинается игра, когда игрок подходит на расстояние около 1.5 метра к экрану и стоит несколько секунд, в это время отображается прогрессбар. Если перед экраном никого нет некоторое время, игра завершается, и экран возвращается к показу слайдшоу из картинок. Осталось немного доработать интерфейс: добавить индикатор текущего положения игрока и прикрутить таблицу рекордов.

Код для измерения расстояний. Немного математики для сглаживания показаний.
import serial
import time
import collections
from typing import Optional, List, Union

class UARTDistanceSensor:
    
    def __init__(self, filter_size: int = 5):
        self.port = '/dev/ttyACM0'
        self.baudrate = 9600
        self.timeout = 0.1
        self.filter_size = filter_size
        
        # Initialize reading history
        self.reading_history = collections.deque(maxlen=filter_size)
        self.reading_history_total = collections.deque(maxlen=filter_size)
        self.last_valid_distance = -1.0
        
        # Serial connection
        self.serial_conn: Optional[serial.Serial] = None
        
        # Error tracking
        self.error_count = 0
        self.max_errors = 10
        
    def connect(self) -> bool:
        try:
            self.serial_conn = serial.Serial(
                port=self.port,
                baudrate=self.baudrate,
                timeout=self.timeout,
                bytesize=serial.EIGHTBITS,
                parity=serial.PARITY_NONE,
                stopbits=serial.STOPBITS_ONE
            )
            
            # Allow time for serial port to initialize
            time.sleep(2)
            
            # Clear any buffered data
            if self.serial_conn.in_waiting:
                self.serial_conn.reset_input_buffer()
                
            print(f"UART connected on {self.port} at {self.baudrate} baud")
            return True
            
        except (serial.SerialException, OSError) as e:
            print(f"Failed to connect to UART on {self.port}: {e}")
            self.serial_conn = None
            return False
    
    def _extract_distance(self, line: str) -> Optional[float]:
        """
        Extract distance value from sensor output line
        
        Args:
            line: Sensor output string (e.g., "Distance: 115.8 cm")
            
        Returns:
            float: Distance in cm, or None if extraction failed
        """
        try:
            # Remove whitespace and split by common delimiters
            line = line.strip()
            # Look for patterns like "Distance: 115.8 cm" or "115.8 cm"
            if "Distance:" in line:
                # Extract number after "Distance:"
                parts = line.split("Distance:")
                if len(parts) > 1:
                    number_part = parts[1].strip()
            else:
                number_part = line
            
            # Extract the first number from the string
            import re
            matches = re.findall(r"[-+]?\d*\.\d+|\d+", number_part)
            
            if matches:
                distance = float(matches[0])
                
                # Validate distance range (adjust as needed for your sensor)
                if 0.0 <= distance <= 300.0:  # Assuming max 10m range
                    return distance
                else:
                    return -1
            else:
                return -1
                
        except (ValueError, IndexError, AttributeError) as e:
            return -1
    
    def get_raw_distance(self) -> Optional[float]:
        """
        Read and parse raw distance from sensor
        
        Returns:
            float: Distance in cm, or None if reading failed
        """
        if self.serial_conn is None or not self.serial_conn.is_open:
            if not self.connect():
                return -1
        
        try:
            # Read a line from the serial port
            if self.serial_conn.in_waiting:
                line = self.serial_conn.readline().decode('utf-8', errors='ignore')

                if line:
                    distance = self._extract_distance(line)
                    if distance > -1:
                        self.error_count = 0  # Reset error counter on success
                        return distance
                    else:
                        if self.error_count < 99: 
                            self.error_count += 1
                        if self.error_count >= self.max_errors:
                            print(f"Warning: UART - {self.error_count} consecutive read errors")
            
            return -1
            
        except (serial.SerialException, UnicodeDecodeError, OSError) as e:
            self.error_count += 1
            print(f"Error reading from UART: {e}")
            
            # Try to reconnect if we have too many errors
            if self.error_count >= self.max_errors:
                self.disconnect()
                time.sleep(1)
                self.connect()
                
            return -1
    
    def median_filter(self, readings: List[float]) -> float:
        """Apply median filter to readings"""
        if not readings:
            return -1.0
        
        # Remove None values
        valid_readings = [r for r in readings if r > -1]
        
        if not valid_readings:
            return -1.0
        
        # Sort readings and get median
        sorted_readings = sorted(valid_readings)
        n = len(sorted_readings)
        
        if n % 2 == 1:
            # Odd number of elements
            median = sorted_readings[n // 2]
        else:
            # Even number of elements
            median = (sorted_readings[n // 2 - 1] + sorted_readings[n // 2]) / 2.0
            
        return median
    
    def moving_average_filter(self, readings: List[float]) -> float:
        """Apply moving average filter to readings"""
        if not readings:
            return -1.0
        
        # Remove None values
        valid_readings = [r for r in readings if r > -1]
        
        if not valid_readings:
            return self.last_valid_distance if self.last_valid_distance > 0 else -1.0
        
        # Remove outliers based on last valid distance
        filtered_readings = []
        for reading in valid_readings:
            if self.last_valid_distance > 0:
                # Allow 30cm jumps maximum (adjust as needed)
                filtered_readings.append(reading)
            else:
                filtered_readings.append(reading)
        
        if not filtered_readings:
            return self.last_valid_distance if self.last_valid_distance > 0 else -1.0
        
        # Calculate weighted average (recent readings have more weight)
        total = 0.0
        weight_sum = 0
        for i, reading in enumerate(filtered_readings):
            weight = i + 1  # Linear weighting (recent = higher weight)
            total += reading * weight
            weight_sum += weight
            
        return total / float(weight_sum)
    
    def get_distance(self, use_filter: str = 'average') -> float:
        """
        Get smoothed distance reading
        
        Args:
            use_filter: 'median', 'average', or 'raw'
            
        Returns:
            float: Distance in cm, or -1.0 if error
        """
        # Get raw reading
        raw_distance = self.get_raw_distance()
        
        # Track raw distance for debugging
        self.raw_distance = raw_distance
        
        # Add to history if valid
        self.reading_history_total.append(raw_distance)
        
        if raw_distance > 0:
            self.reading_history.append(raw_distance)
        
        # If we don't have enough history, return raw or last valid
        if len(self.reading_history) < self.filter_size:
            if raw_distance > 0:
                self.last_valid_distance = raw_distance
                return raw_distance
            else:
                return self.last_valid_distance if self.last_valid_distance > 0 else -1.0
        
        # Apply selected filter
        if use_filter == 'median':
            filtered = self.median_filter(list(self.reading_history))
        elif use_filter == 'average':
            filtered = self.moving_average_filter(list(self.reading_history))
        else:  # 'raw'
            filtered = raw_distance if raw_distance > 0 else self.last_valid_distance
        
        # Update last valid distance if we got a good reading
        if filtered >= 0:
            self.last_valid_distance = filtered
            
        return filtered
    
    def get_reading_quality(self) -> int:
        """Get quality indicator of readings (0-100%)"""
        if len(self.reading_history_total) == 0:
            return 0
        
        # Count valid readings
        valid_count = sum(1 for reading in self.reading_history_total 
                         if reading > 0)
        
        return int((valid_count / float(len(self.reading_history_total))) * 100)
    
    def flush_buffer(self) -> None:
        """Clear serial input buffer"""
        if self.serial_conn and self.serial_conn.is_open:
            self.serial_conn.reset_input_buffer()
    
    def disconnect(self) -> None:
        """Close serial connection"""
        if self.serial_conn and self.serial_conn.is_open:
            self.serial_conn.close()
            print(f"UART disconnected")
    
    def cleanup(self) -> None:
        """Clean up resources"""
        self.disconnect()
Код самой игры
import traceback
import time
import random
import math
import RPi.GPIO as GPIO
from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics
from PIL import Image, ImageDraw, ImageFont
import sys
import os
import collections
import sqlite3
from datetime import datetime, timedelta
import pytz
import math
from distance_sensor import *

show_debug = False
#show_debug = True

def get_brightness():
    # Use pytz for timezone handling
    moscow_tz = pytz.timezone('Europe/Moscow')
    now = datetime.now(moscow_tz)

    # Get day of year
    day_of_year = now.timetuple().tm_yday
    
    # Approximate calculation for Moscow (latitude ~55.75)
    # This is a simplified calculation - for production consider using a proper library
    
    # Solar declination angle (simplified)
    declination = 23.45 * math.sin(math.radians(360.0/365.0 * (day_of_year - 81)))
    
    # Hour angle for sunrise/sunset
    lat_rad = math.radians(55.212300)  # Moscow latitude
    
    # Sunset hour angle
    sunset_hour_angle = math.degrees(math.acos(-math.tan(lat_rad) * math.tan(math.radians(declination))))
    
    # Sunrise and sunset in hours from solar noon
    sunrise_hours = 12.0 - sunset_hour_angle/15.0
    sunset_hours = 12.0 + sunset_hour_angle/15.0
    
    # Apply equation of time correction (simplified)
    B = math.radians(360.0/365.0 * (day_of_year - 81))
    equation_of_time = 9.87 * math.sin(2*B) - 7.53 * math.cos(B) - 1.5 * math.sin(B)
    
    sunrise_hours -= equation_of_time/60.0
    sunset_hours -= equation_of_time/60.0
    
    # Create datetime objects for sunrise and sunset
    sunrise_time = now.replace(hour=int(sunrise_hours), 
                               minute=int((sunrise_hours % 1) * 60),
                               second=0, microsecond=0) + timedelta(minutes=30)
    sunset_time = now.replace(hour=int(sunset_hours), 
                              minute=int((sunset_hours % 1) * 60),
                              second=0, microsecond=0) + timedelta(minutes=30)
    # Calculate transition periods
    sunrise_start = sunrise_time - timedelta(minutes=30)
    sunrise_end = sunrise_time + timedelta(minutes=30)
    sunset_start = sunset_time - timedelta(minutes=30)
    sunset_end = sunset_time + timedelta(minutes=30)
    
    # Determine the current period
    if sunrise_start <= now <= sunrise_end:
        return 60
    elif sunset_start <= now <= sunset_end:
        return 60
    elif sunrise_end <= now <= sunset_start:
        return 100
    else:
        return 30

class GameScores:
    def __init__(self, db='/game-db/scores.db'):
        self.conn = sqlite3.connect(db)
        self.c = self.conn.cursor()
        self.c.execute('CREATE TABLE IF NOT EXISTS scores (score INT, date TEXT)')
        self.conn.commit()
    
    def save_score(self, score):
        date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        self.c.execute('SELECT MAX(score) FROM scores')
        max_score = self.c.fetchone()[0] or 0
        is_high = score >= max_score
        self.c.execute('INSERT INTO scores VALUES (?,?)', (score, date))
        self.conn.commit()
        return is_high
    
    def get_top_scores(self, n=5):
        self.c.execute('SELECT score FROM scores ORDER BY score DESC LIMIT ?', (n,))
        return [r[0] for r in self.c.fetchall()]
    
    def close(self):
        self.conn.close()

class Game:
   
    def __init__(self, display, sensors):
        self.display = display
        self.sensors = sensors
        self.screen_width = 128
        self.screen_height = 64
        
        # Game states
        self.STATE_SLIDESHOW = 1
        self.STATE_PLAYING = 2
        self.STATE_GAME_OVER = 3
        self.STATE_HIGH_SCORES = 4

        
        self.state = self.STATE_SLIDESHOW
        self.state_start_time = time.time()
        self.calibration_start_time = time.time()
        self.last_game_over_time = time.time()
        self.calibration_good_time = 0

        # Slideshow variables
        self.slideshow_images = []
        self.current_slide_index = 0
        self.slide_start_time = time.time()
        self.fade_state = 0  # 0: normal, 1-100: fading out, 101-200: fading in
        self.fade_alpha = 0
        self.next_slide_image = None
        self.load_slideshow_images()
        
        # Game play variables
        self.score = 0
        self.start_time = 0
        self.game_time = 0
        self.speed_multiplier = 0.8  # Slower start
        self.speed_increase_timer = 0
        
        # Calibration variables
        self.calibration_start_time = 0
        self.calibration_good_time = 0
        
        # Spaceship properties (square shape)
        self.ship_width = 8
        self.ship_height = 8
        self.ship_x = 15  # Fixed horizontal position
        self.ship_y = self.screen_height // 2  # Start in middle
        self.ship_speed_y = 0
        
        # Asteroids - EASIER SETTINGS
        self.asteroids = []
        self.asteroid_spawn_timer = 0
        self.asteroid_spawn_delay = 1.5  # Slower spawn rate
        self.max_asteroids = 3  # Fewer asteroids at once
        
        # Colors
        self.color_ship = (0, 255, 0)  # Green
        self.color_asteroid = (255, 100, 0)  # Orange
        self.color_text = (255, 255, 255)  # White
        self.color_game_over = (255, 0, 0)  # Red
        self.color_distance_bar = (0, 100, 255)  # Blue
        self.color_calibration = (0, 200, 200)  # Cyan
        self.color_countdown = (255, 255, 0)  # Yellow
        self.color_green = (0, 255, 0)
        self.color_yellow = (255, 255, 0)
        self.color_red = (255, 0, 0)
        self.color_white = (255, 255, 255)
        self.color_blue = (0, 0, 255)

        # Distance calibration (120-170 cm usable range)
        self.min_distance = 100
        self.max_distance = 150

        self.raw_distance = -1
        self.last_distance = -1
        self.last_quality = 0

        self.scores = GameScores()
        self.is_high_score = False

        

    def load_slideshow_images(self):
        """Load images for slideshow with display times"""
        # Define image paths and display times in seconds
        image_config = [
            ("/root/ledPi/logo1.gif", 10),
            ("/root/ledPi/logo2.gif", 5)
#            ("/root/ledPi/9-may.gif", 30)
            #("/root/ledPi/logo-newyear.gif", 10),
            #("/root/ledPi/logo-vote.gif", 10)
        ]
        
        for image_path, display_time in image_config:
            try:
                img = Image.open(image_path)
                # Resize to fit display if needed
                if img.size != (self.screen_width, self.screen_height):
                    img = img.resize((self.screen_width, self.screen_height), Image.LANCZOS)
                self.slideshow_images.append({
                    'image': img.convert('RGB'),
                    'display_time': display_time
                })
                print(f"Loaded image: {image_path}")
            except Exception as e:
                print("Error loading image", e)
                # Create a placeholder if image fails to load
                placeholder = Image.new('RGB', (self.screen_width, self.screen_height), 
                                      color=(random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)))
                draw = ImageDraw.Draw(placeholder)
                draw.text((10, 20), os.path.basename(image_path), fill=(255, 255, 255))
                self.slideshow_images.append({
                    'image': placeholder,
                    'display_time': display_time
                })

    def reset_game(self):
        """Reset game to initial state"""
        self.score = 0
        self.start_time = time.time()
        self.game_time = 0
        self.speed_multiplier = 0.8  # Slower start
        self.speed_increase_timer = time.time()
        
        self.ship_y = self.screen_height // 2
        self.ship_speed_y = self.screen_height // 2
        
        self.asteroids = []
        self.asteroid_spawn_timer = time.time()
        self.asteroid_spawn_delay = 1.5  # Slower spawn rate
        
        self.state = self.STATE_SLIDESHOW
        self.state_start_time = time.time()
        self.calibration_start_time = time.time()
        self.last_game_over_time = time.time()
        self.calibration_good_time = 0
        
    def map_distance_to_y(self, distance):
        """Map distance reading to screen Y position (120-170 cm range)"""
        if distance < 0:
            return self.last_valid_distance
            
        # Clamp distance to usable range
        if distance < self.min_distance:
            clamped_dist = self.min_distance
        elif distance > self.max_distance:
            clamped_dist = self.max_distance
        else:
            clamped_dist = distance
        
        # Normalize (120cm -> top of screen, 170cm -> bottom of screen)
        normalized = (clamped_dist - self.min_distance) / (self.max_distance - self.min_distance)
        
        # Map to screen coordinates (with margin)
        margin = 5
        min_y = margin
        max_y = self.screen_height - margin - self.ship_height
        
        # Calculate Y position
        y_pos = min_y + int(normalized * (max_y - min_y))
        
        # Keep within bounds
        if y_pos < min_y:
            y_pos = min_y
        elif y_pos > max_y:
            y_pos = max_y
            
        return y_pos
    
    def map_distance_to_speed_y(self, distance):
        """Map distance reading to screen Y position (120-170 cm range)"""
        if distance < 0:
            return 0
            
        # Clamp distance to usable range
        if distance < self.min_distance:
            clamped_dist = self.min_distance
        elif distance > self.max_distance:
            clamped_dist = self.max_distance
        else:
            clamped_dist = distance
        
        # Normalize (120cm -> top of screen, 170cm -> bottom of screen)
        normalized = (clamped_dist - self.min_distance) / (self.max_distance - self.min_distance)
        

        # Map to screen coordinates (with margin)
        max_speed_y = 5
        
        # Calculate Y position
        speed_y = max_speed_y * (normalized - 0.5)
           
        return speed_y
    
    def create_asteroid(self):
        """Create a new asteroid - fewer and smaller"""
        if len(self.asteroids) >= self.max_asteroids:
            return
            
        asteroid = {
            'x': self.screen_width + 10,
            'y': random.randint(5, self.screen_height - 5),
            'size': random.randint(2, 4),  # Smaller asteroids
            'speed': random.uniform(1.0, 2.0) * self.speed_multiplier,  # Slower
            'type': random.choice(['small', 'medium'])
        }
        
        # Adjust color based on size
        if asteroid['size'] <= 3:
            asteroid['color'] = (180, 180, 180)  # Light gray for small
        else:
            asteroid['color'] = (200, 120, 50)   # Orange-brown for medium
            
        self.asteroids.append(asteroid)
    
    def update_asteroids(self):
        """Update asteroid positions"""
        current_time = time.time()
        
        # Spawn new asteroids (slower rate)
        if (current_time - self.asteroid_spawn_timer > self.asteroid_spawn_delay and 
            len(self.asteroids) < self.max_asteroids):
            self.create_asteroid()
            self.asteroid_spawn_timer = current_time
        
        # Update existing asteroids
        asteroids_to_remove = []
        for i, asteroid in enumerate(self.asteroids):
            asteroid['x'] -= asteroid['speed']
            
            # Remove if off screen
            if asteroid['x'] < -20:
                asteroids_to_remove.append(i)
                self.score += 5  # Less points for dodging
        
        # Remove off-screen asteroids
        for i in sorted(asteroids_to_remove, reverse=True):
            del self.asteroids[i]
        
        # Gradually increase game speed over time (slower increase)
        if current_time - self.speed_increase_timer > 12:  # Every 12 seconds
            self.speed_multiplier = min(3.0, self.speed_multiplier * 1.1)  # Slower increase
            self.asteroid_spawn_delay = max(0.8, self.asteroid_spawn_delay * 0.95)  # Minor spawn increase
            self.max_asteroids = min(5, self.max_asteroids + 1)  # Gradually add more asteroids
            self.speed_increase_timer = current_time
    
    def check_collision(self):
        """Check for collisions between ship and asteroids"""
        ship_left = self.ship_x
        ship_right = self.ship_x + self.ship_width
        ship_top = self.ship_y
        ship_bottom = self.ship_y + self.ship_height
        
        for asteroid in self.asteroids:
            asteroid_left = asteroid['x'] - asteroid['size']
            asteroid_right = asteroid['x'] + asteroid['size']
            asteroid_top = asteroid['y'] - asteroid['size']
            asteroid_bottom = asteroid['y'] + asteroid['size']
            
            # Simple AABB collision detection
            if (ship_right > asteroid_left and 
                ship_left < asteroid_right and 
                ship_bottom > asteroid_top and 
                ship_top < asteroid_bottom):
                return True
        
        return False
    
    def draw_distance_bar(self, draw, current_distance):
        """Draw distance indicator bar on right side"""
        bar_width = 0
        bar_x = 0
        bar_height = 64
        bar_y = 0
        
        if current_distance >= 0 and self.min_distance <= current_distance <= self.max_distance and self.last_quality >= 20:
            # Calculate fill height
            normalized = (current_distance - self.min_distance) / (self.max_distance - self.min_distance)
            fill_height = int(bar_height * normalized)
            fill_y = bar_y + fill_height
            draw.rectangle([bar_x, fill_y, bar_x + bar_width, bar_y + bar_height], 
                          fill=self.color_distance_bar)
        else:
            # Calculate fill height
            draw.rectangle([bar_x, 0, bar_x + bar_width, 63], 
                          fill=(100, 0, 0))
    
    def update(self):
        """Update game state based on current state"""
        # Get distance reading
        distance = self.sensors[0].get_distance()
        quality = self.sensors[0].get_reading_quality()
        error_count = self.sensors[0].error_count

        self.last_distance = distance
        self.last_quality = quality 
        self.error_count = error_count
        print('self.last_distance', self.last_distance)

        #print("quality: ", quality, 'distance', distance)

        if self.state == self.STATE_SLIDESHOW:
            self.update_slideshow()
               
        elif self.state == self.STATE_PLAYING:
            # Update ship position based on distance
            if distance >= 0:
                self.ship_speed_y = self.map_distance_to_speed_y(distance)
                margin = 5
                min_y = margin
                max_y = self.screen_height - margin - self.ship_height
                self.ship_y += self.ship_speed_y
                if self.ship_y > max_y:
                    self.ship_y = max_y
                if self.ship_y < min_y:
                    self.ship_y = min_y
            
            # Update asteroids
            self.update_asteroids()
            
            # Check for collisions
            if self.check_collision():
                self.game_time = time.time() - self.start_time
                self.state = self.STATE_GAME_OVER
                self.state_start_time = time.time()
                self.final_score = self.score
                self.is_high_score = self.scores.save_score(self.final_score)
        
            
            # Update score based on survival time
            self.score = int((time.time() - self.start_time) * 10)

        elif self.state == self.STATE_GAME_OVER:
            # Show game over screen for 5 seconds
            if ((not self.is_high_score and time.time() - self.state_start_time >= 5) or (self.is_high_score and time.time() - self.state_start_time >= 20)):
                self.state = self.STATE_HIGH_SCORES
                self.state_start_time = time.time()
                self.last_game_over_time = time.time()

        elif self.state == self.STATE_HIGH_SCORES:
            # Show high scores screen for 5 seconds, then return to slideshow
            if time.time() - self.state_start_time >= 60:
                self.state = self.STATE_SLIDESHOW
                self.state_start_time = time.time()
                self.current_slide_index = 0
                self.slide_start_time = time.time()
                self.fade_state = 0

        if self.state in (self.STATE_SLIDESHOW, self.STATE_HIGH_SCORES):
            # Check if distance is in usable range
            if distance >= self.min_distance and distance <= self.max_distance and error_count < 30:
                if self.calibration_good_time == 0:
                    self.calibration_good_time = time.time()
                elif ((time.time() - self.calibration_good_time >= 5) or (time.time() - self.calibration_good_time >= 5 and time.time() - self.last_game_over_time <= 60)):
                    # Good for 5 seconds, start countdown
                    self.reset_game()
                    self.state = self.STATE_PLAYING
                    self.state_start_time = time.time()
            else:
                #print("dist", distance, "qual", quality)
                self.calibration_good_time = 0


    def update_slideshow(self):
        """Update slideshow state"""
        current_time = time.time()
        #print((self.fade_alpha, self.fade_state))
        
        if not self.slideshow_images:
            return
            
        current_slide = self.slideshow_images[self.current_slide_index]
        display_time = current_slide['display_time']
        
        # Handle fading between slides
        if self.fade_state == 0:
            # Normal display - check if time to fade out
            if current_time - self.slide_start_time >= display_time - 1.0:  # Start fade 1 second before end
                self.fade_state = 1
                self.fade_alpha = 0
        elif 1 <= self.fade_state <= 100:
            # Fading out current slide
            self.fade_alpha = self.fade_state
            self.fade_state += 4  # Adjust fade speed
            
            if self.fade_state > 100:
                self.fade_state = 101
                # Prepare next slide
                next_index = (self.current_slide_index + 1) % len(self.slideshow_images)
                self.next_slide_image = self.slideshow_images[next_index]['image']
        elif 101 <= self.fade_state <= 200:
            # Fading in next slide
            self.fade_alpha = self.fade_state - 101
            self.fade_state += 4  # Adjust fade speed
            
            if self.fade_state > 200:
                # Transition complete
                self.current_slide_index = (self.current_slide_index + 1) % len(self.slideshow_images)
                self.slide_start_time = current_time
                self.fade_state = 0
                self.fade_alpha = 0
                self.next_slide_image = None


    def draw_ship(self, draw):
        """Draw the square spaceship"""
        # Main body (square)
        draw.rectangle([self.ship_x, self.ship_y, 
                       self.ship_x + self.ship_width, 
                       self.ship_y + self.ship_height], 
                      fill=self.color_ship)
        
        # Cockpit (small square in center)
        cockpit_size = 4
        cockpit_x = self.ship_x + (self.ship_width - cockpit_size) // 2
        cockpit_y = self.ship_y + (self.ship_height - cockpit_size) // 2
        draw.rectangle([cockpit_x, cockpit_y, 
                       cockpit_x + cockpit_size, 
                       cockpit_y + cockpit_size], 
                      fill=(0, 100, 0))
        
        # Engine exhaust (small squares)
        exhaust_x = self.ship_x - 2
        for i in range(2):
            draw.rectangle([exhaust_x, self.ship_y + 2 + i*3, 
                           exhaust_x + 1, self.ship_y + 3 + i*3], 
                          fill=(255, 100 + i*30, 0))
    
    def draw_asteroids(self, draw):
        """Draw all asteroids"""
        for asteroid in self.asteroids:
            # Draw asteroid as a circle
            left = asteroid['x'] - asteroid['size']
            top = asteroid['y'] - asteroid['size']
            right = asteroid['x'] + asteroid['size']
            bottom = asteroid['y'] + asteroid['size']
            
            # Main asteroid body
            draw.ellipse([left, top, right, bottom], 
                        fill=asteroid['color'])
    
    def draw_hud(self, draw):
        """Draw heads-up display during gameplay"""
        # Score
        score_text = str(self.score)
        draw.text((100, 2), score_text, font=self.display.font_small, 
                 fill=self.color_text)
        
    def draw_distance(self, draw):
        """Draw heads-up display during gameplay"""
        # Score
        draw.rectangle([8, 2, 58, 24], fill=(0, 0, 0))
        score_text = str(int(self.last_distance)) + " " + str(int(self.error_count))
        draw.text((10, 2), score_text, font=self.display.font_small, 
                 fill=self.color_text)
        score_text = str(int(self.sensors[0].raw_distance))
        draw.text((10, 14), score_text, font=self.display.font_small, 
                 fill=self.color_text)

    def draw_calibration_screen(self, draw, distance):
       
        # Status indicator
        if (distance >= self.min_distance and distance <= self.max_distance):
            # Progress bar for 5-second hold
            if self.calibration_good_time > 0:
                hold_time = time.time() - self.calibration_good_time
                if time.time() - self.last_game_over_time <= 60:
                    progress = min(1.0, max(hold_time, 0.0) / 5.0)
                else:
                    progress = min(1.0, max(hold_time - 2.0, 0.0) / 15.0)

                bar_width = 128
                bar_height = 1
                bar_x = 0
                bar_y = 63
  
                # Progress
                fill_width = int(bar_width * progress)
                draw.rectangle([bar_x, bar_y, bar_x + bar_width, bar_y + bar_height], 
                              fill=(0, 0, 0))
                bar_color = (int(255 * progress), int(201 * progress), int(135 * progress))
                if self.state == self.STATE_HIGH_SCORES:
                    bar_color = (int(100 * progress), int(100 * progress), int(100 * progress))
                draw.rectangle([bar_x, bar_y, bar_x + fill_width, bar_y + bar_height], fill=bar_color)
    
    def draw_game_over_screen(self):
        """Draw game over screen"""
        # Background
        draw = self.display.draw
        draw.rectangle((0, 0, self.screen_width, self.screen_height), 
                      fill=(0, 0, 0))

        if self.is_high_score:
            img = Image.open("/root/ledPi/crowns.gif").convert('RGB')
            self.display.image.paste(img, (0, 0))

        # Score
        score_text = "{}".format(self.final_score)
        # Get text size using textbbox (Pillow 8.0+)
        bbox = draw.textbbox((0, 0), score_text, font=self.display.font_xlarge)
        score_width = bbox[2] - bbox[0]
        score_height = bbox[3] - bbox[1]
        draw.text(((self.screen_width - score_width) // 2, 10), 
                 score_text, font=self.display.font_xlarge, 
                 fill=(100, 100, 100))
        
    def draw_high_scores_screen(self):
        """Draw game over screen"""
        # Background
        draw = self.display.draw
        draw.rectangle((0, 0, self.screen_width, self.screen_height), 
                      fill=(0, 0, 0))
        

        high_score_list = self.scores.get_top_scores(10)
        color = (100, 100, 100)
        current_line = 0

        #print(high_score_list)
        for cur_score in high_score_list[:5]:
            score_text = str(current_line+1) + '. ' + str(cur_score)
            # Get text size using textbbox (Pillow 8.0+)
            bbox = draw.textbbox((0, 0), score_text, font=self.display.font_hscore)
            score_width = bbox[2] - bbox[0]
            score_height = bbox[3] - bbox[1]
            if current_line == 0:
                color = (255, 215, 0)
            elif current_line == 1:
                color = (197, 201, 199)
            elif current_line == 2:
                color = (205, 127, 50)
            else:
                color = (100, 100, 100)
            draw.text(((self.screen_width - score_width) // 2 - 30, -1 + current_line * 12), 
                 score_text, font=self.display.font_hscore, 
                 fill=color)
            current_line += 1

        current_line = 0
        for cur_score in high_score_list[5:]:
            score_text = str(current_line+6) + '. ' + str(cur_score)
            # Get text size using textbbox (Pillow 8.0+)
            bbox = draw.textbbox((0, 0), score_text, font=self.display.font_hscore)
            score_width = bbox[2] - bbox[0]
            score_height = bbox[3] - bbox[1]
            draw.text(((self.screen_width - score_width) // 2 + 30, -1 + current_line * 12), 
                 score_text, font=self.display.font_hscore, 
                 fill=color)
            current_line += 1
        
    def draw_slideshow_screen(self, draw):
           
        current_slide = self.slideshow_images[self.current_slide_index]
        black_img = Image.new('RGB', (self.screen_width, self.screen_height), (0, 0, 0))
        
        if self.fade_state == 0:
            # Normal display
            draw.rectangle((0, 0, self.screen_width, self.screen_height), fill=(0, 0, 0))
            self.display.image.paste(current_slide['image'], (0, 0))
        elif 1 <= self.fade_state <= 100:
            # Fading out
            alpha = self.fade_state / 100.0
            current_img = current_slide['image'].copy()
            blended = Image.blend(black_img, current_img, 1.0 - alpha)
            self.display.image.paste(blended, (0, 0))
        elif 101 <= self.fade_state <= 200 and self.next_slide_image:
            # Fading in
            alpha = (200.0 - self.fade_state) / 100.0
            blended = Image.blend(black_img, self.next_slide_image, 1.0 - alpha)
            self.display.image.paste(blended, (0, 0))
    
    def draw_playing_screen(self, draw, distance):
        """Draw gameplay screen"""
        # Space background
        draw.rectangle((0, 0, self.screen_width, self.screen_height), 
                      fill=(5, 5, 20))
        
        # Stars
        for _ in range(15):  # Fewer stars
            x = random.randint(0, self.screen_width)
            y = random.randint(0, self.screen_height)
            brightness = random.randint(150, 255)
            self.display.draw.point((x, y), fill=(brightness, brightness, 255))
        
        # Game elements
        self.draw_asteroids(draw)
        self.draw_ship(draw)
        self.draw_hud(draw)
        self.draw_distance_bar(draw, distance)
    
    def draw(self):
        """Draw the entire game frame based on current state"""
        # Clear canvas
        #self.display.draw.rectangle((0, 0, self.screen_width, self.screen_height), 
        #                           fill=(0, 0, 0))
        
        self.display.matrix.brightness = get_brightness()

        if self.state == self.STATE_SLIDESHOW:
            self.draw_slideshow_screen(self.display.draw)
            
        elif self.state == self.STATE_GAME_OVER:
            self.draw_game_over_screen()

        elif self.state == self.STATE_HIGH_SCORES:
            self.draw_high_scores_screen()

        elif self.state == self.STATE_PLAYING:
            self.draw_playing_screen(self.display.draw, self.last_distance)

        if self.state in (self.STATE_SLIDESHOW, self.STATE_HIGH_SCORES):
            self.draw_calibration_screen(self.display.draw, self.last_distance)

        if show_debug:
            self.draw_distance(self.display.draw)

        # Update matrix
        self.display.matrix.SetImage(self.display.image.convert('RGB'))

class DistanceDisplay:
    """Class to display on RGB LED matrix (128x64)"""
    
    def __init__(self, rows=64, cols=128, chain_length=2, parallel=1):
        """Initialize RGB matrix display for 128x64"""
        self.rows = rows
        self.cols = cols
        
        # Configuration for 128x64 matrix
        options = RGBMatrixOptions()
        options.rows = 32
        options.cols = 64
        options.chain_length = 2
        options.parallel = 2
        options.hardware_mapping = 'regular'
        options.multiplexing = 1
        options.gpio_slowdown = 2
        options.scan_mode = 1
        options.pwm_lsb_nanoseconds = 600
        options.show_refresh_rate = False
        options.brightness = get_brightness()
        
        # Create matrix object
        self.matrix = RGBMatrix(options = options)
        
        # Create canvas
        self.image = Image.new("RGB", (cols, rows))
        self.draw = ImageDraw.Draw(self.image)
        
        # Try to load fonts
        try:
            self.font_xlarge = ImageFont.truetype("/root/ledPi/Squary.ttf", 60)
            self.font_hscore = ImageFont.truetype("/root/ledPi/Squary.ttf", 24)
            self.font_large = ImageFont.truetype("/root/ledPi/FreeSansBold.ttf", 20)
            self.font_small = ImageFont.truetype("/root/ledPi/FreeSans.ttf", 12)
            self.font_tiny = ImageFont.truetype("/root/ledPi/FreeSans.ttf", 10)
        except:
            self.font_large = ImageFont.load_default()
            self.font_small = ImageFont.load_default()
            self.font_tiny = ImageFont.load_default()
        
        # Colors
        self.color_green = (0, 255, 0)
        self.color_yellow = (255, 255, 0)
        self.color_red = (255, 0, 0)
        self.color_white = (255, 255, 255)
        self.color_blue = (0, 0, 255)
    
    def clear(self):
        """Clear the display"""
        self.matrix.Clear()
    
    def cleanup(self):
        """Clean up display resources"""
        self.matrix.Clear()

def main():
    """Main application function"""
    
    
    # Matrix configuration for 128x64
    MATRIX_ROWS = 64
    MATRIX_COLS = 128
    MATRIX_CHAIN = 2
    MATRIX_PARALLEL = 1
    
    # Game update interval
    UPDATE_INTERVAL = 0.05  # 20 FPS
    try:
        # Initialize sensor
        sensor = UARTDistanceSensor()
#        sensor2 = AJSR04MSensor(0, 21, 'right')
        
        # Initialize display
        display = DistanceDisplay(rows=MATRIX_ROWS, 
                                 cols=MATRIX_COLS,
                                 chain_length=MATRIX_CHAIN,
                                 parallel=MATRIX_PARALLEL)
        
        # Initialize game (starts in splash screen)
        game = Game(display, [sensor])
        
        # Main game loop
        while True:
            try:
                # Update game state
                game.update()
                
                # Draw game frame
                game.draw()
                
                # Wait before next frame
                time.sleep(UPDATE_INTERVAL)
                
            except KeyboardInterrupt:
                print("\nGame stopped by user.")
                break
            except Exception as e:
                print(f"Error in game loop: {e}")
                traceback.print_exc()
                time.sleep(0.1)
                
    except KeyboardInterrupt:
        print("\nApplication terminated by user.")
    except Exception as e:
        print(f"Fatal error: {e}")
    finally:
        print("\nCleaning up resources...")
        try:
            display.clear()
            sensor.cleanup()
        except:
            pass

if __name__ == "__main__":
    main()

Возможности открываются очень большие, есть куда развиваться. Например, при установлении рекорда делать фото победителя и отправлять его в группу в мессенджере. Добавить распознавание образов и подстраивать реакцию экрана на появление человека в зависимости от роста: по-разному приветствовать детей и взрослых. Используя нейросети, показывать изображения и текст, сгенерированные на основании фото человека, который подошел (например, какой-нибудь комплимент, учитывающий пол, возраст, одежду человека). Есть проводное подключение к интернету, значит, все это сможет работать в реальном времени, без задержек. Сделать взаимодействие с пользователем через мессенджер или приложение (например, дать пользователю возможность что-то написать на экране, поздравить кого-то с днем рождения и тому подобное).

Основным техническим вызовом была наладка стабильной работы ультразвукового датчика. Светодиодные модули, судя по всему, создают значительные помехи в цепях raspberry pi, и в итоге датчик расстояния работает нестабильно. Эта взаимосвязь заметна даже глазу: когда картинка резко меняется, показания начинают прыгать, когда картинка стабильна, показания также приходят в норму. Что я только ни пробовал, но в итоге проблему решил кардинально: взял дополнительную arduino, которая считывает показания датчика расстояния и передает их через текстовый вывод в serial port на raspberry pi, подключается просто по USB.

Слева Raspberry Pi без корпуса, подключенная к двум рядам по два LED-модуля. Справа -- arduino, считывающая показания с ультразвукового датчика. Посередине блок питания 5В для LED модулей.
Слева Raspberry Pi без корпуса, подключенная к двум рядам по два LED-модуля. Справа -- arduino, считывающая показания с ультразвукового датчика. Посередине блок питания 5В для LED модулей.

Ультразвуковой датчик изначально планировалось встроить в одну из "ног", на которых стоит экран, но почему-то показания при этом становились нестабильными. Причины этого мне неизвестны, видимо, как-то влиял тот факт что ноги сделаны из металла. Поэтому датчик пришлось разместить под корпусом. Некрасиво, зато работает.

Ультразвуковой датчик
Ультразвуковой датчик
Вид сзади, вентиляционная решетка для охлаждения.
Вид сзади, вентиляционная решетка для охлаждения.

На этом я заканчиваю рассказ о технических решениях, использованных в нашем посёлке. Было реализовано еще много всего полезного, но не такого интересного (программа для ведения документооборота и отчетности ТСН, агрегатор событий на КПП, подсчет статистики загруженности участков дороги). Если вы хотите поучаствовать в жизни своего дома/поселка и повторить что-то из описанного, или может быть просто поговорить о том, как организовать работу, смело обращайтесь, постараюсь помочь.