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

Страницы:  1

Ответить
 

Professor Seleznov


Третья (и пока что заключительная) часть об общественно-полезных DIY-проектах в посёлке (часть 1, часть 2). Расскажу про светодиодные экраны и игровой автомат для детской площадки.
Однажды выяснилось, что мы должны на въезде в поселок разместить информационный стенд, на котором можно было бы прочитать, кто обслуживает поселок, контактные телефоны и тому подобное. Уверен, многие видели такие стенды и в посёлках и в многоквартирных домах.
pic
Типичный информационный стенд. Картинка из интернета.
Выглядят стенды в большинстве своем ужасно, пользоваться ими неудобно (а фактически никто и не пользуется), поэтому возникла идея выполнить формальное требование, но в виде экрана, на котором отображалась бы полезная информация.
Первая мысль была: добыть какой-нибудь большой телевизор и повесить его под навесом. Но выяснилось, что модели, защищенные от влаги, стоят очень дорого, на солнце их видно плохо, а разрешение у них сильно избыточно.
Вначале попробовали сделать экран из адресной светодиодной ленты, но быстро стало понятно, что для реального применения он не подходит: для просмотра в солнечную погоду все пространство между светодиодами должно быть черным, а значит, нужно делать накладку с отверстиями под каждый светодиод. К тому же, разрешение получалось очень низким, а значит, экран должен иметь большие размеры чтобы на него влезли хотя бы несколько слов текста. Столько свободного места у нас не было.
pic
Первые прототипы экранов
Но на улицах же часто встречаются рекламные конструкции в виде экранов, как-то их изготавливают, а значит, сможем изготовить и мы. Выяснилось, что экраны, даже самые большие, собираются из относительно небольших модулей. Модули эти бывают обычные и уличные, именно таких 4 штуки и были добыты на алиэкспрессе.
Встал вопрос: что может выступать источником для отображения видео на этих модулях? Оказалось, с этим вполне справляются даже микроконтроллеры типа ESP32.
Что работает на одном светодиодном модуле, заработает и на нескольких. Модули объединяются в цепочки при помощи шлейфов и получается экран произвольного размера. Слишком длинные цепочки будут медленно обновляться, тогда экран делят на несколько независимых цепочек и делят картинку между ними на уровне контроллера (как будто подключают несколько отдельных экранов).
pic
Пример подключения большого экрана из 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 модули, вставлена внутрь рамки из деревянных досок, собрано все на саморезы и покрашено в черный матовый.
pic
Чем проще графика, тем лучше она смотрится на таком экране. Черный фон очень желателен.
Игровой автомат
Второй экран решили поставить на местной площади, рядом с детской площадкой. Цель та же: отображать различные объявления и тому подобное. И тут вспомнилась старая моя идея: сделать уличный игровой автомат, с которым могли бы взаимодействовать все желающие. Сначала хотел делать нажимаемые ногами кнопки, но в итоге остановился на такой механике: при помощи ультразвукового датчика замерять расстояние до игрока и далее он будет подходить и отходить, а по экрану будет двигаться персонаж. Получается 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
# 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.
pic
Слева Raspberry Pi без корпуса, подключенная к двум рядам по два LED-модуля. Справа -- arduino, считывающая показания с ультразвукового датчика. Посередине блок питания 5В для LED модулей.
Ультразвуковой датчик изначально планировалось встроить в одну из "ног", на которых стоит экран, но почему-то показания при этом становились нестабильными. Причины этого мне неизвестны, видимо, как-то влиял тот факт что ноги сделаны из металла. Поэтому датчик пришлось разместить под корпусом. Некрасиво, зато работает.
pic
Ультразвуковой датчик
pic
Вид сзади, вентиляционная решетка для охлаждения.
На этом я заканчиваю рассказ о технических решениях, использованных в нашем посёлке. Было реализовано еще много всего полезного, но не такого интересного (программа для ведения документооборота и отчетности ТСН, агрегатор событий на КПП, подсчет статистики загруженности участков дороги). Если вы хотите поучаствовать в жизни своего дома/поселка и повторить что-то из описанного, или может быть просто поговорить о том, как организовать работу, смело обращайтесь, постараюсь помочь.-Источник
 
Loading...
Error