Автоматизируем посёлок ч.3: LED-экран и игровой автомат

от автора

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Код программы
import timeimport threadingimport mathimport osimport loggingfrom rgbmatrix import RGBMatrix, RGBMatrixOptionsfrom PIL import Image, ImageDraw, ImageFontfrom datetime import datetime, timedeltaimport pytzimport sysimport jsontry:    from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer    from urlparse import urlparse, parse_qs    import SocketServerexcept ImportError:    from http.server import BaseHTTPRequestHandler, HTTPServer    from urllib.parse import urlparse, parse_qs    import socketserver as SocketServerdef 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 = 32options.cols = 64options.chain_length = 2options.parallel = 2options.hardware_mapping = 'regular'options.multiplexing = 1options.gpio_slowdown = 2options.scan_mode = 1options.pwm_lsb_nanoseconds = 600options.show_refresh_rate = Falseoptions.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 = 3class 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 = 5display = 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 imagedef 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 imagedef 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 imagedef 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 imagedef 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 serialimport timeimport collectionsfrom typing import Optional, List, Unionclass 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 tracebackimport timeimport randomimport mathimport RPi.GPIO as GPIOfrom rgbmatrix import RGBMatrix, RGBMatrixOptions, graphicsfrom PIL import Image, ImageDraw, ImageFontimport sysimport osimport collectionsimport sqlite3from datetime import datetime, timedeltaimport pytzimport mathfrom distance_sensor import *show_debug = False#show_debug = Truedef 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 30class 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:            passif __name__ == "__main__":    main()

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

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

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

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

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

Ультразвуковой датчик

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

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1035352/