Arduinum/mvp 1 #1
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
STREAM_URL="url адрес видеопотока"
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -172,3 +172,7 @@ cython_debug/
|
|||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
36
README.md
36
README.md
@@ -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
1922
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||||
|
|||||||
14
src/web_robot_control/main.py
Normal file
14
src/web_robot_control/main.py
Normal 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)
|
||||||
36
src/web_robot_control/settings.py
Normal file
36
src/web_robot_control/settings.py
Normal 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()
|
||||||
83
src/web_robot_control/views.py
Normal file
83
src/web_robot_control/views.py
Normal 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
89
static/command.js
Normal 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
57
static/index.html
Normal 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
34
static/style.css
Normal 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; /* Автоматически изменяет высоту изображения пропорционально ширине */
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user