Третья (и пока что заключительная) часть об общественно-полезных DIY-проектах в посёлке (часть 1, часть 2). Расскажу про светодиодные экраны и игровой автомат для детской площадки.
Однажды выяснилось, что мы должны на въезде в поселок разместить информационный стенд, на котором можно было бы прочитать, кто обслуживает поселок, контактные телефоны и тому подобное. Уверен, многие видели такие стенды и в посёлках и в многоквартирных домах.
Выглядят стенды в большинстве своем ужасно, пользоваться ими неудобно (а фактически никто и не пользуется), поэтому возникла идея выполнить формальное требование, но в виде экрана, на котором отображалась бы полезная информация.
Первая мысль была: добыть какой-нибудь большой телевизор и повесить его под навесом. Но выяснилось, что модели, защищенные от влаги, стоят очень дорого, на солнце их видно плохо, а разрешение у них сильно избыточно.
Вначале попробовали сделать экран из адресной светодиодной ленты, но быстро стало понятно, что для реального применения он не подходит: для просмотра в солнечную погоду все пространство между светодиодами должно быть черным, а значит, нужно делать накладку с отверстиями под каждый светодиод. К тому же, разрешение получалось очень низким, а значит, экран должен иметь большие размеры чтобы на него влезли хотя бы несколько слов текста. Столько свободного места у нас не было.
Но на улицах же часто встречаются рекламные конструкции в виде экранов, как-то их изготавливают, а значит, сможем изготовить и мы. Выяснилось, что экраны, даже самые большие, собираются из относительно небольших модулей. Модули эти бывают обычные и уличные, именно таких 4 штуки и были добыты на алиэкспрессе.
Встал вопрос: что может выступать источником для отображения видео на этих модулях? Оказалось, с этим вполне справляются даже микроконтроллеры типа ESP32.
Что работает на одном светодиодном модуле, заработает и на нескольких. Модули объединяются в цепочки при помощи шлейфов и получается экран произвольного размера. Слишком длинные цепочки будут медленно обновляться, тогда экран делят на несколько независимых цепочек и делят картинку между ними на уровне контроллера (как будто подключают несколько отдельных экранов).
В принципе, на этом уже можно было остановиться: загрузить в контроллер картинки и он бы их показывал по кругу. Но захотелось большего: подключить экран к локальной сети по проводу (на улице 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.
Ультразвуковой датчик изначально планировалось встроить в одну из «ног», на которых стоит экран, но почему-то показания при этом становились нестабильными. Причины этого мне неизвестны, видимо, как-то влиял тот факт что ноги сделаны из металла. Поэтому датчик пришлось разместить под корпусом. Некрасиво, зато работает.
На этом я заканчиваю рассказ о технических решениях, использованных в нашем посёлке. Было реализовано еще много всего полезного, но не такого интересного (программа для ведения документооборота и отчетности ТСН, агрегатор событий на КПП, подсчет статистики загруженности участков дороги). Если вы хотите поучаствовать в жизни своего дома/поселка и повторить что-то из описанного, или может быть просто поговорить о том, как организовать работу, смело обращайтесь, постараюсь помочь.
ссылка на оригинал статьи https://habr.com/ru/articles/1035352/