Merge pull request #1 from Arduinum/arduinum/mvp_1

Arduinum/mvp 1
This commit was merged in pull request #1.
This commit is contained in:
Eugene
2025-07-18 16:51:47 +03:00
committed by GitHub
11 changed files with 1371 additions and 909 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
STREAM_URL="url адрес видеопотока"

4
.gitignore vendored
View File

@@ -172,3 +172,7 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# IDE
.idea/
.vscode/

View File

@@ -0,0 +1,36 @@
# Web-robot-control
**Web-robot-control** - open source веб-приложение для управлением роботом и трансляции видео с веб-камеры.
## Запуск приложения
**Запуск для локальной разработки**: `poetry run uvicorn web_robot_control.main:app --host server_ip
--port port_number`
**Todo:** создать Python-функцию для запуска веб-приложения и добавить её в скрипты Poetry
<details>
<summary>
<strong>
Как оформлять ветки и коммиты
</strong>
</summary>
Пример ветки `user_name/name_task`
- **user_name** (имя пользователя);
- **name_task** (название задачи).
Пример коммита `refactor: renaming a variable`
- **feat:** (новая функционал кода, БЕЗ учёта функционала для сборок);
- **devops:** (функционал для сборки, - добавление, удаление и исправление);
- **fix:** (исправление ошибок функционального кода);
- **docs:** (изменения в документации);
- **style:** (форматирование, отсутствующие точки с запятой и т.п., без изменения производственного кода);
- **refactor:** (рефакторинг производственного кода, например, переименование переменной);
- **test:** (добавление недостающих тестов, рефакторинг тестов; без изменения производственного кода);
- **chore:** (обновление рутинных задач и т. д.; без изменения производственного кода).
Оформление основано на https://www.conventionalcommits.org/en/v1.0.0/
</details>

1922
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "web-robot-control" name = "web-robot-control"
version = "0.1.0" version = "0.1.0"
description = "Web-robot-control - веб-приложение для управлением роботом и трансляции видео с веб-камеры." description = "Web-robot-control - open source веб-приложение для управлением роботом и трансляции видео с веб-камеры."
authors = [ authors = [
{name = "Arduinum628",email = "message.chaos628@gmail.com"} {name = "Arduinum628",email = "message.chaos628@gmail.com"}
] ]
@@ -9,7 +9,7 @@ license = {text = "MIT"}
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"streamlit (>=1.44.1,<2.0.0)" "fastapi[all] (>=0.115.12,<0.116.0)"
] ]
[tool.poetry] [tool.poetry]

View File

@@ -0,0 +1,14 @@
from fastapi import FastAPI
from starlette.staticfiles import StaticFiles
from web_robot_control.views import router
# создаем экземпляр FastAPI
app = FastAPI()
# подключаем статические файлы
app.mount('/static', StaticFiles(directory='static'), name='static')
# подключаем роутер
app.include_router(router)

View File

@@ -0,0 +1,36 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class ModelConfig(BaseSettings):
"""Модель конфига"""
model_config = SettingsConfigDict(
env_file = '.env',
env_file_encoding='utf-8',
extra='ignore'
)
class CommandsRobot(ModelConfig):
"""Класс с командами для робота"""
forward: str
backward: str
left: str
right: str
def get_list_commands(self):
"""Метод вернёт список всех команд"""
return list(self.model_dump().values())
class Settings(ModelConfig):
"""Класс для данных конфига"""
stream_url: str
websocket_url_robot: str
commands_robot: CommandsRobot = CommandsRobot()
settings = Settings()

View File

@@ -0,0 +1,83 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, WebSocketException
from fastapi.requests import Request
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from websockets import exceptions, connect
import asyncio
import socket
from web_robot_control.settings import settings
# Создаем объект роутера
router = APIRouter()
# Создаём объект для рендеринга html-шаблонов
templates = Jinja2Templates(directory='static')
@router.get('/', response_class=HTMLResponse)
async def index(request: Request) -> Response:
"""
Асинхронная функция для получения главной страницы приложения.
"""
return templates.TemplateResponse(
request=request,
name='index.html',
context={'title': 'Web-robot-control - Главная', 'name_robot': 'Bot1'}
)
@router.get('/config')
async def get_config() -> dict:
"""Aсинхронная функция для получения stream_url."""
return {'stream_url': settings.stream_url}
async def command_to_robot(command: str) -> str:
"""Асинхронная функция для отправки команды роботу через websockets"""
try:
async with connect(settings.websocket_url_robot) as robot_ws:
await robot_ws.send(command)
response = await robot_ws.recv()
return response
# Todo: для каждой ошибки написать своё сообщение
except (
exceptions.InvalidURI,
asyncio.TimeoutError,
exceptions.ConnectionClosedError,
exceptions.ConnectionClosedOK,
exceptions.InvalidHandshake,
ConnectionRefusedError,
socket.gaierror,
exceptions.InvalidMessage
) as err:
return f'{err.__class__.__name__}: {err}'
@router.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket) -> None:
# Установка содединения по веб-сокету
await websocket.accept()
try:
while True:
# Получение команды от клиента (с веб-сокета)
command = await websocket.receive_text()
valid_commands = settings.commands_robot.get_list_commands()
if command in valid_commands:
# оптравка команды роботу
robot_answer = await command_to_robot(command=command)
if robot_answer:
# отправка ответа робота на вебсокет фронтенда
await websocket.send_text(f'Получена команда: {command}, ответ робота: {robot_answer}')
print(f'Ответ робота: {robot_answer}')
except WebSocketDisconnect:
print('WebSocket отключен') # Todo: для вывода ошибок будет настроен logger
# Todo: для каждой ошибки написать своё сообщение
except (WebSocketException, exceptions.InvalidMessage) as err:
print(f'{err.__class__.__name__}: {err}')

89
static/command.js Normal file
View File

@@ -0,0 +1,89 @@
// Ждём, пока загрузится весь контент DOM
document.addEventListener("DOMContentLoaded", () => {
// Создаём WebSocket-соединение с сервером
const ws = new WebSocket(`ws://${window.location.host}/ws`);
// Делаем запрос к серверу на эндпоинт "/config"
fetch('/config')
.then(response => response.json()) // Преобразуем ответ в JSON
.then(data => {
// Получаем значение stream_url из ответа
const streamUrl = data.stream_url;
const videoElement = document.getElementById("video-stream");
// Устанавливаем URL видеопотока в элемент <video>
videoElement.src = streamUrl;
});
// Обработчик события открытия WebSocket-соединения
ws.onopen = function() {
console.log("WebSocket подключен");
};
// Обработчик события получения сообщения по WebSocket
ws.onmessage = function(event) {
console.log("Получено:", event.data); // Выводим полученные данные
};
// Обработчик события закрытия WebSocket-соединения
ws.onclose = function() {
console.log("WebSocket закрыт");
};
// Обработчик ошибок WebSocket
ws.onerror = function(error) {
console.log("WebSocket ошибка:", error); // Логируем ошибку
};
let commandInterval;
// Функция для начала отправки команды с заданным интервалом
function startSendingCommand(command) {
// Отправляем команду сразу
sendCommand(command);
// Запускаем интервал для повторной отправки команды
commandInterval = setInterval(() => {
sendCommand(command); // Повторяем отправку команды
}, 10); // Интервал отправки — каждые 10 мс
}
// Функция для остановки отправки команд
function stopSendingCommand() {
// Останавливаем интервал
clearInterval(commandInterval);
}
// Функция для отправки команды через WebSocket
function sendCommand(command) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(command); // Отправляем команду через WebSocket
console.log("Команда:", command); // Логируем отправленную команду
} else {
console.log("WebSocket не подключён"); // Если WebSocket не открыт
}
}
// Назначение обработчиков событий для кнопки "Вперёд"
const forwardButton = document.getElementById("forward-button");
forwardButton.addEventListener("mousedown", () => startSendingCommand("forward")); // Начало отправки команды
forwardButton.addEventListener("mouseup", stopSendingCommand); // Остановка отправки при отпускании кнопки
forwardButton.addEventListener("mouseleave", stopSendingCommand); // Остановка отправки, если курсор уходит с кнопки
// Назначение обработчиков событий для кнопки "Влево"
const leftButton = document.getElementById("left-button");
leftButton.addEventListener("mousedown", () => startSendingCommand("left"));
leftButton.addEventListener("mouseup", stopSendingCommand);
leftButton.addEventListener("mouseleave", stopSendingCommand);
// Назначение обработчиков событий для кнопки "Вправо"
const rightButton = document.getElementById("right-button");
rightButton.addEventListener("mousedown", () => startSendingCommand("right"));
rightButton.addEventListener("mouseup", stopSendingCommand);
rightButton.addEventListener("mouseleave", stopSendingCommand);
// Назначение обработчиков событий для кнопки "Назад"
const backwardButton = document.getElementById("backward-button");
backwardButton.addEventListener("mousedown", () => startSendingCommand("backward"));
backwardButton.addEventListener("mouseup", stopSendingCommand);
backwardButton.addEventListener("mouseleave", stopSendingCommand);
});

57
static/index.html Normal file
View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/command.js"></script>
</head>
<body>
<div class="container mt-5">
<h1 class="text-center mb-4">Управление роботом {{ name_robot }}</h1>
<!-- Видеопоток -->
<div class="row justify-content-center">
<div class="col-md-7 px-0">
<div class="card">
<div class="card-body text-center">
<img src="" class="img-fluid" id="video-stream" alt="Видеопоток">
</div>
<div class="line"></div>
<!-- Кнопки -->
<div class="col-md-12 d-flex align-items-center px-0">
<div class="card-command card d-flex flex-column justify-content-center align-items-center">
<!-- Вверх -->
<button class="btn btn-warning m-1" id="forward-button">
<i class="bi bi-arrow-up"></i>
</button>
<div class="d-flex">
<!-- Влево -->
<button class="btn btn-warning m-1" id="left-button">
<i class="bi bi-arrow-left"></i>
</button>
<!-- Вниз -->
<button class="btn btn-warning m-1" id="backward-button">
<i class="bi bi-arrow-down"></i>
</button>
<!-- Вправо -->
<button class="btn btn-warning m-1" id="right-button">
<i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

34
static/style.css Normal file
View File

@@ -0,0 +1,34 @@
/* Задает общий фон страницы */
body {
background-color: #f8f9fa;
}
/* Создает тень для карточки, добавляя глубину и визуальную привлекательность */
.card {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* Устанавливает черный цвет фона для элементов с классами card-command и card-body */
.card-command, .card-body {
background-color: black;
}
/* Устанавливает стили для блока card-command */
.card-command {
width: 100%; /* Задает ширину в 100% */
border-top-left-radius: 0; /* Убирает закругление верхнего левого угла */
border-top-right-radius: 0; /* Убирает закругление верхнего правого угла */
}
/* Определяет стили для горизонтальной линии между элементами */
.line {
background-color: #ffc107; /* Задает желтый цвет линии */
height: 6px; /* Высота линии */
width: 100%; /* Линия занимает всю ширину блока */
}
/* Определяет максимальную ширину изображения и делает его адаптивным */
img {
max-width: 100%; /* Ограничивает ширину изображения, чтобы оно не выходило за пределы контейнера */
height: auto; /* Автоматически изменяет высоту изображения пропорционально ширине */
}