Compare commits
66 Commits
3db3a9bdcd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa3d172a8 | ||
|
|
5b403d7ee4 | ||
|
|
48aa91b8cd | ||
|
|
1b305500a8 | ||
|
|
4509753ac5 | ||
|
|
243541fcd6 | ||
|
|
8dcaee3d61 | ||
|
|
5a9ce4f5c4 | ||
|
|
51400a5762 | ||
|
|
19c1919fc5 | ||
|
|
594a62a9f6 | ||
|
|
387823d36a | ||
|
|
118e49c7f9 | ||
|
|
78069b0aa4 | ||
|
|
877b29d5b4 | ||
|
|
eab9e17212 | ||
|
|
827210d29a | ||
|
|
5d15f215be | ||
|
|
311d09f0b3 | ||
|
|
7fc5b79bf5 | ||
|
|
6f7617b174 | ||
|
|
d8b25e188d | ||
|
|
18d005c933 | ||
|
|
9bbcd67065 | ||
|
|
627c18c2b3 | ||
|
|
e92c7d8f09 | ||
|
|
fd627257f5 | ||
|
|
35caa2bb72 | ||
|
|
0cea7d5e9d | ||
|
|
ec16f2467f | ||
|
|
d785f4a481 | ||
|
|
580b78e5c0 | ||
|
|
30923eb9ce | ||
|
|
5d2d37fc7b | ||
|
|
5938d94a8f | ||
|
|
4180353b65 | ||
|
|
bc40c1437a | ||
|
|
875aa8a054 | ||
|
|
9525097af6 | ||
|
|
9a17569ac0 | ||
|
|
87ab5d48de | ||
|
|
2dc1bb647f | ||
|
|
96c280c2b8 | ||
|
|
ea972129cb | ||
|
|
b4649f61ca | ||
|
|
40ac0dd2b4 | ||
|
|
730facdf59 | ||
|
|
d84856ba3a | ||
|
|
676a674794 | ||
|
|
354156e557 | ||
|
|
ac0e3944ab | ||
|
|
3cceda32f4 | ||
|
|
24b7cc402e | ||
|
|
9d6452c860 | ||
|
|
b00af27058 | ||
|
|
8e296daacc | ||
|
|
941a7cf6e6 | ||
|
|
f6416ca106 | ||
|
|
1a5e50ae1f | ||
|
|
361ffc5578 | ||
|
|
ae56334637 | ||
|
|
02e6bd7b47 | ||
|
|
f4a21e6974 | ||
|
|
5433a036aa | ||
|
|
e36823d3ba | ||
|
|
549b0c5e63 |
5
.claudeignore
Normal file
5
.claudeignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.git/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
31
.gitattributes
vendored
Normal file
31
.gitattributes
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# 1. Глобальное правило по умолчанию
|
||||
# Назначает автоматическую обработку текста: в репозитории ВСЕГДА будет LF.
|
||||
* text=auto eol=lf
|
||||
|
||||
# 2. Явное указание текстовых файлов для исходного кода и конфигураций
|
||||
*.go text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.md text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.conf text eol=lf
|
||||
*.service text eol=lf
|
||||
*.socket text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.py text eol=lf
|
||||
*.c text eol=lf
|
||||
*.h text eol=lf
|
||||
Makefile text eol=lf
|
||||
makefile text eol=lf
|
||||
|
||||
# 3. Исключения для бинарных файлов (чтобы Git не пытался менять в них байты)
|
||||
*.png binary
|
||||
*.woff2 binary
|
||||
*.woff binary
|
||||
*.ttf binary
|
||||
*.gz binary
|
||||
# папка build и файлы внутри debian часто содержат бинарники или генерируемые файлы
|
||||
build/* binary
|
||||
95
.gitignore
vendored
95
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
# ===========================================
|
||||
# GO
|
||||
# ===========================================
|
||||
|
||||
# Бинарные файлы (скомпилированные программы)
|
||||
gpio-monitor-server
|
||||
progress-server
|
||||
@@ -12,22 +16,105 @@ progress-server
|
||||
|
||||
# Результаты тестов с покрытием
|
||||
*.out
|
||||
*.cov
|
||||
*.html
|
||||
|
||||
# Зависимости
|
||||
vendor/
|
||||
|
||||
# ===========================================
|
||||
# NODE.JS / NPM / TYPESCRIPT
|
||||
# ===========================================
|
||||
|
||||
# Зависимости node_modules
|
||||
node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Скомпилированные JS файлы (если не хотите хранить их в репозитории)
|
||||
# Раскомментируйте если не храните dist в Git
|
||||
# dist/
|
||||
# build/
|
||||
|
||||
# Логи npm
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Кэш npm
|
||||
.npm/
|
||||
.eslintcache
|
||||
|
||||
# ===========================================
|
||||
# ОБЩИЕ ДЛЯ ПРОЕКТА
|
||||
# ===========================================
|
||||
|
||||
# Временные файлы и сборки
|
||||
tmp/
|
||||
temp/
|
||||
build/
|
||||
|
||||
# Файлы IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.suo
|
||||
*.user
|
||||
*.sln
|
||||
*.csproj
|
||||
|
||||
# Системные файлы
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Временные файлы
|
||||
tmp/
|
||||
build/
|
||||
temp/
|
||||
# Файлы окружения
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# ===========================================
|
||||
# СПЕЦИФИЧНЫЕ ДЛЯ ВАШЕГО ПРОЕКТА
|
||||
# ===========================================
|
||||
|
||||
# Старые JS файлы (если перешли на TypeScript)
|
||||
# Раскомментируйте когда удалите старые .js файлы из js/
|
||||
# cmd/server/web/js/*.js
|
||||
# !cmd/server/web/js/*.ts
|
||||
|
||||
# Тестовые изображения
|
||||
test.jpg
|
||||
*.jpg
|
||||
*.png
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.bmp
|
||||
|
||||
# Логи приложения
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# PID файлы
|
||||
*.pid
|
||||
*.pid.lock
|
||||
|
||||
# Файлы дампа памяти
|
||||
*.dump
|
||||
*.core
|
||||
|
||||
# Debian packaging
|
||||
*.deb
|
||||
*.dsc
|
||||
*.changes
|
||||
debian/*.log
|
||||
debian/*.debhelper
|
||||
debian/*.substvars
|
||||
debian/files
|
||||
debian/gpio-monitor-server/
|
||||
debian/debhelper-build-stamp
|
||||
debian/.debhelper/
|
||||
286
Makefile
Normal file
286
Makefile
Normal file
@@ -0,0 +1,286 @@
|
||||
# Makefile для GPIO Monitor Server
|
||||
|
||||
.PHONY: all build clean run test fmt lint help install dev deb prepare-debian version deb-clean deb-info deb-contents
|
||||
|
||||
# Переменные
|
||||
APP_NAME := gpio-monitor-server
|
||||
CMD_PATH := ./cmd/server
|
||||
BUILD_DIR := ./build
|
||||
BINARY := $(BUILD_DIR)/$(APP_NAME)
|
||||
WEB_DIR := ./cmd/server/web
|
||||
DEB_DIR := ./debian
|
||||
DEB_PACKAGE := $(DEB_DIR)/$(APP_NAME)
|
||||
|
||||
# Целевая платформа (по умолчанию)
|
||||
GOOS ?= linux
|
||||
GOARCH ?= arm64
|
||||
|
||||
# Версионирование DEB пакета
|
||||
BASE_VERSION := $(shell awk '/^Version:/ {print $$2; exit}' $(DEB_DIR)/control 2>/dev/null)
|
||||
BASE_VERSION := $(if $(BASE_VERSION),$(BASE_VERSION),1.0.0)
|
||||
GIT_COMMIT_COUNT := $(shell git rev-list --count HEAD 2>/dev/null || echo 0)
|
||||
DEB_VERSION := $(BASE_VERSION)-build$(GIT_COMMIT_COUNT)
|
||||
DEB_FILENAME := $(APP_NAME)_$(DEB_VERSION)_$(GOARCH).deb
|
||||
|
||||
# Флаги сборки
|
||||
LDFLAGS := -ldflags="-s -w"
|
||||
|
||||
# Цвета для вывода
|
||||
RED := \033[0;31m
|
||||
GREEN := \033[0;32m
|
||||
YELLOW := \033[0;33m
|
||||
NC := \033[0m # No Color
|
||||
|
||||
# ============================================
|
||||
# ИНИЦИАЛИЗАЦИЯ И ПОДГОТОВКА СИСТЕМЫ
|
||||
# ============================================
|
||||
|
||||
.PHONY: init check-node
|
||||
|
||||
## init - Полная подготовка окружения после git clone
|
||||
init: check-node deps
|
||||
@echo "$(GREEN)Проект успешно инициализирован и готов к сборке!$(NC)"
|
||||
@echo "Запустите '$(IMAGE_TARGET) make build' для сборки или 'make run' для запуска."
|
||||
|
||||
# Проверка установленного Node.js и npm
|
||||
check-node:
|
||||
@echo "$(YELLOW)Проверка окружения...$(NC)"
|
||||
@which node > /dev/null 2>&1 || (echo "$(RED)❌ Ошибка: Node.js не установлен!$(NC)" && echo "Установите его командой: sudo apt install nodejs npm" && exit 1)
|
||||
@which npm > /dev/null 2>&1 || (echo "$(RED)❌ Ошибка: npm не установлен!$(NC)" && exit 1)
|
||||
@echo "$(GREEN) Node.js и npm найдены.$(NC)"
|
||||
|
||||
# ============================================
|
||||
# ОСНОВНЫЕ ЦЕЛИ
|
||||
# ============================================
|
||||
|
||||
all: help
|
||||
|
||||
## build - Полная сборка проекта (фронтенд + бекенд)
|
||||
build: build-frontend build-backend
|
||||
@echo "$(GREEN)Сборка завершена!$(NC)"
|
||||
@ls -lh $(BINARY)
|
||||
|
||||
## build-frontend - Сборка TypeScript фронтенда
|
||||
build-frontend:
|
||||
@echo "$(YELLOW)Сборка TypeScript фронтенда...$(NC)"
|
||||
@cd $(WEB_DIR) && \
|
||||
if [ ! -d "node_modules" ]; then \
|
||||
echo " Установка npm зависимостей..."; \
|
||||
npm install; \
|
||||
fi && \
|
||||
echo " Компиляция TypeScript..." && \
|
||||
npm run build && \
|
||||
echo "$(GREEN)TypeScript скомпилирован успешно!$(NC)"
|
||||
|
||||
## build-backend - Сборка Go сервера
|
||||
build-backend:
|
||||
@echo "$(YELLOW)Сборка Go сервера...$(NC)"
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@echo " Целевая платформа: $(GOOS)/$(GOARCH)"
|
||||
@GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LDFLAGS) -o $(BINARY) $(CMD_PATH)
|
||||
@echo "$(GREEN)Go сервер скомпилирован успешно!$(NC)"
|
||||
|
||||
## run - Запуск сервера (сборка + запуск)
|
||||
run: build
|
||||
@echo "$(YELLOW)Запуск сервера...$(NC)"
|
||||
@$(BINARY)
|
||||
|
||||
## dev - Режим разработки (без оптимизации, с hot-reload)
|
||||
dev:
|
||||
@echo "$(YELLOW)Режим разработки...$(NC)"
|
||||
@go run $(CMD_PATH) -port :8080
|
||||
|
||||
## clean - Очистка артефактов сборки
|
||||
clean:
|
||||
@echo "$(YELLOW)Очистка...$(NC)"
|
||||
@rm -rf $(BUILD_DIR)
|
||||
@rm -rf $(WEB_DIR)/dist
|
||||
@rm -rf $(WEB_DIR)/node_modules
|
||||
@rm -rf $(DEB_PACKAGE)
|
||||
@rm -f *.deb
|
||||
@echo "$(GREEN)Очищено$(NC)"
|
||||
|
||||
## test - Запуск тестов
|
||||
test:
|
||||
@echo "$(YELLOW)Запуск тестов...$(NC)"
|
||||
@go test -v ./...
|
||||
|
||||
## fmt - Форматирование кода
|
||||
fmt:
|
||||
@echo "$(YELLOW)Форматирование кода...$(NC)"
|
||||
@go fmt ./...
|
||||
@cd $(WEB_DIR) && npm run format 2>/dev/null || true
|
||||
|
||||
## lint - Проверка кода
|
||||
lint:
|
||||
@echo "$(YELLOW)Проверка кода...$(NC)"
|
||||
@golangci-lint run ./... 2>/dev/null || echo " Установите golangci-lint: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"
|
||||
|
||||
## deps - Установка зависимостей
|
||||
deps:
|
||||
@echo "$(YELLOW)Установка зависимостей Go...$(NC)"
|
||||
go mod download
|
||||
go mod tidy
|
||||
@echo "$(YELLOW)Установка npm-пакетов фронтенда (это может занять время)...$(NC)"
|
||||
cd $(WEB_DIR) && npm install --progress=true --loglevel=info
|
||||
|
||||
## install - Установка бинарника в систему
|
||||
install: build
|
||||
@echo "$(YELLOW)Установка $(APP_NAME)...$(NC)"
|
||||
@sudo cp $(BINARY) /usr/local/bin/$(APP_NAME)
|
||||
@echo "$(GREEN)Установлено в /usr/local/bin/$(APP_NAME)$(NC)"
|
||||
|
||||
## uninstall - Удаление из системы
|
||||
uninstall:
|
||||
@echo "$(YELLOW)🗑Удаление $(APP_NAME)...$(NC)"
|
||||
@sudo rm -f /usr/local/bin/$(APP_NAME)
|
||||
@echo "$(GREEN)Удалено$(NC)"
|
||||
|
||||
## help - Показать справку
|
||||
help:
|
||||
@echo "$(GREEN)GPIO Monitor Server - команды Makefile$(NC)"
|
||||
@echo ""
|
||||
@echo "Доступные команды:"
|
||||
@awk '/^##/ { \
|
||||
cmd = $$0; \
|
||||
gsub(/^## /, "", cmd); \
|
||||
printf " $(GREEN)%-20s$(NC) %s\n", $$2, cmd \
|
||||
}' $(MAKEFILE_LIST) | sort
|
||||
@echo ""
|
||||
@echo "Примеры:"
|
||||
@echo " make build - Полная сборка"
|
||||
@echo " make run - Сборка и запуск"
|
||||
@echo " make dev - Запуск в режиме разработки"
|
||||
@echo " make clean - Очистка"
|
||||
@echo " make install - Установка в систему"
|
||||
@echo " make deb - Сборка DEB пакета"
|
||||
|
||||
# ============================================
|
||||
# СБОРКА DEB ПАКЕТА
|
||||
# ============================================
|
||||
|
||||
## prepare-debian - Подготовка структуры для deb-пакета
|
||||
prepare-debian:
|
||||
@echo "$(YELLOW)Подготовка структуры DEB пакета...$(NC)"
|
||||
@# Создаем основную структуру
|
||||
@mkdir -p $(DEB_PACKAGE)/DEBIAN
|
||||
@mkdir -p $(DEB_PACKAGE)/usr/bin
|
||||
@mkdir -p $(DEB_PACKAGE)/usr/share/doc/$(APP_NAME)
|
||||
@mkdir -p $(DEB_PACKAGE)/opt/gpio-monitoring/web
|
||||
@mkdir -p $(DEB_PACKAGE)/etc/gpio-monitoring
|
||||
@mkdir -p $(DEB_PACKAGE)/etc/systemd/system
|
||||
@# Копируем DEBIAN файлы
|
||||
@sed -e 's/^Version:.*/Version: $(DEB_VERSION)/' \
|
||||
-e 's/^Architecture:.*/Architecture: $(GOARCH)/' \
|
||||
$(DEB_DIR)/control > $(DEB_PACKAGE)/DEBIAN/control
|
||||
@if [ -f $(DEB_DIR)/preinst ]; then cp $(DEB_DIR)/preinst $(DEB_PACKAGE)/DEBIAN/; fi
|
||||
@if [ -f $(DEB_DIR)/postinst ]; then cp $(DEB_DIR)/postinst $(DEB_PACKAGE)/DEBIAN/; fi
|
||||
@if [ -f $(DEB_DIR)/prerm ]; then cp $(DEB_DIR)/prerm $(DEB_PACKAGE)/DEBIAN/; fi
|
||||
@if [ -f $(DEB_DIR)/postrm ]; then cp $(DEB_DIR)/postrm $(DEB_PACKAGE)/DEBIAN/; fi
|
||||
@# Копируем документацию
|
||||
@if [ -f $(DEB_DIR)/copyright ]; then cp $(DEB_DIR)/copyright $(DEB_PACKAGE)/usr/share/doc/$(APP_NAME)/; fi
|
||||
@if [ -f $(DEB_DIR)/changelog ]; then \
|
||||
cp $(DEB_DIR)/changelog $(DEB_PACKAGE)/usr/share/doc/$(APP_NAME)/; \
|
||||
gzip -9 -n $(DEB_PACKAGE)/usr/share/doc/$(APP_NAME)/changelog 2>/dev/null || true; \
|
||||
fi
|
||||
@# Устанавливаем права
|
||||
@chmod 755 $(DEB_PACKAGE)/DEBIAN/* 2>/dev/null || true
|
||||
@chmod 644 $(DEB_PACKAGE)/DEBIAN/control $(DEB_PACKAGE)/DEBIAN/md5sums 2>/dev/null || true
|
||||
@echo "$(GREEN)Структура подготовлена$(NC)"
|
||||
|
||||
## deb - Сборка DEB пакета
|
||||
deb: clean build-frontend build-backend prepare-debian
|
||||
@echo "$(YELLOW)Сборка DEB пакета...$(NC)"
|
||||
@# Копируем бинарник
|
||||
@cp $(BINARY) $(DEB_PACKAGE)/usr/bin/
|
||||
@# Копируем веб-файлы
|
||||
@if [ -d "$(WEB_DIR)/dist" ]; then cp -r $(WEB_DIR)/dist $(DEB_PACKAGE)/opt/gpio-monitoring/web/; fi
|
||||
@if [ -d "$(WEB_DIR)/css" ]; then cp -r $(WEB_DIR)/css $(DEB_PACKAGE)/opt/gpio-monitoring/web/; fi
|
||||
@if [ -d "$(WEB_DIR)/fonts" ]; then cp -r $(WEB_DIR)/fonts $(DEB_PACKAGE)/opt/gpio-monitoring/web/; fi
|
||||
@if ls $(WEB_DIR)/*.html 1>/dev/null 2>&1; then cp $(WEB_DIR)/*.html $(DEB_PACKAGE)/opt/gpio-monitoring/web/ 2>/dev/null || true; fi
|
||||
@if [ -f "$(WEB_DIR)/favicon.png" ]; then cp $(WEB_DIR)/favicon.png $(DEB_PACKAGE)/opt/gpio-monitoring/web/ 2>/dev/null || true; fi
|
||||
@# Копируем systemd сервис
|
||||
@if [ -f "$(DEB_DIR)/$(APP_NAME).service" ]; then cp $(DEB_DIR)/$(APP_NAME).service $(DEB_PACKAGE)/etc/systemd/system/; fi
|
||||
@# Копируем конфиг
|
||||
@if [ -f "$(DEB_DIR)/$(APP_NAME).conf" ]; then cp $(DEB_DIR)/$(APP_NAME).conf $(DEB_PACKAGE)/etc/gpio-monitoring/config; fi
|
||||
@# Копируем скрипт для логов
|
||||
@if [ -f "$(DEB_DIR)/usr/bin/gpio-logs" ]; then \
|
||||
cp $(DEB_DIR)/usr/bin/gpio-logs $(DEB_PACKAGE)/usr/bin/; \
|
||||
chmod +x $(DEB_PACKAGE)/usr/bin/gpio-logs; \
|
||||
fi
|
||||
@# Устанавливаем правильные права
|
||||
@chown -R root:root $(DEB_PACKAGE) 2>/dev/null || true
|
||||
@# Создаем архив с контрольными суммами
|
||||
@cd $(DEB_PACKAGE) && find . -type f ! -path "./DEBIAN/*" -exec md5sum {} \; > DEBIAN/md5sums 2>/dev/null || true
|
||||
@# Собираем пакет
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@dpkg-deb --root-owner-group --build $(DEB_PACKAGE) $(BUILD_DIR)/$(DEB_FILENAME)
|
||||
@echo "$(GREEN)DEB пакет создан: $(BUILD_DIR)/$(DEB_FILENAME)$(NC)"
|
||||
@ls -lh $(BUILD_DIR)/$(DEB_FILENAME)
|
||||
@echo ""
|
||||
@echo "$(GREEN)Информация о пакете:$(NC)"
|
||||
@dpkg-deb -I $(BUILD_DIR)/$(DEB_FILENAME) | head -15
|
||||
|
||||
# ============================================
|
||||
# ДОПОЛНИТЕЛЬНЫЕ ЦЕЛИ
|
||||
# ============================================
|
||||
|
||||
## build-arm64 - Сборка для ARM64 (Raspberry Pi)
|
||||
build-arm64:
|
||||
@$(MAKE) build GOOS=linux GOARCH=arm64
|
||||
|
||||
## build-amd64 - Сборка для AMD64
|
||||
build-amd64:
|
||||
@$(MAKE) build GOOS=linux GOARCH=amd64
|
||||
|
||||
## build-mac - Сборка для macOS
|
||||
build-mac:
|
||||
@$(MAKE) build GOOS=darwin GOARCH=amd64
|
||||
|
||||
## build-windows - Сборка для Windows
|
||||
build-windows:
|
||||
@$(MAKE) build GOOS=windows GOARCH=amd64
|
||||
|
||||
## build-all - Сборка для всех платформ
|
||||
build-all: build-arm64 build-amd64 build-mac build-windows
|
||||
@echo "$(GREEN)Все платформы собраны$(NC)"
|
||||
|
||||
## docker-build - Сборка Docker образа
|
||||
docker-build:
|
||||
@echo "$(YELLOW)Сборка Docker образа...$(NC)"
|
||||
@docker build -t gpio-monitor:latest .
|
||||
|
||||
## docker-run - Запуск в Docker
|
||||
docker-run:
|
||||
@echo "$(YELLOW)Запуск в Docker...$(NC)"
|
||||
@docker run -p 8080:8080 gpio-monitor:latest
|
||||
|
||||
## version - Показать вычисленную версию и имя deb-пакета
|
||||
version:
|
||||
@echo "BASE_VERSION: $(BASE_VERSION)"
|
||||
@echo "GIT_COMMIT_COUNT: $(GIT_COMMIT_COUNT)"
|
||||
@echo "DEB_VERSION: $(DEB_VERSION)"
|
||||
@echo "DEB_FILENAME: $(DEB_FILENAME)"
|
||||
|
||||
## deb-clean - Очистка временных файлов deb-пакета
|
||||
deb-clean:
|
||||
@echo "$(YELLOW)Очистка deb-временных файлов...$(NC)"
|
||||
@rm -rf $(DEB_PACKAGE)
|
||||
@rm -f $(BUILD_DIR)/$(APP_NAME)_*.deb
|
||||
@echo "$(GREEN)Очищено$(NC)"
|
||||
|
||||
## deb-info - Показать информацию о deb-пакете
|
||||
deb-info:
|
||||
@if [ -f "$(BUILD_DIR)/$(DEB_FILENAME)" ]; then \
|
||||
dpkg-deb -I $(BUILD_DIR)/$(DEB_FILENAME); \
|
||||
else \
|
||||
echo "$(RED)Пакет не найден. Сначала выполните: make deb$(NC)"; \
|
||||
fi
|
||||
|
||||
## deb-contents - Показать содержимое deb-пакета
|
||||
deb-contents:
|
||||
@if [ -f "$(BUILD_DIR)/$(DEB_FILENAME)" ]; then \
|
||||
dpkg-deb -c $(BUILD_DIR)/$(DEB_FILENAME); \
|
||||
else \
|
||||
echo "$(RED)Пакет не найден. Сначала выполните: make deb$(NC)"; \
|
||||
fi
|
||||
206
README.md
206
README.md
@@ -1,101 +1,191 @@
|
||||
# GPIO Monitor Dashboard for Raspberry Pi
|
||||
|
||||
Реалтайм мониторинг параллельной GPIO-шины Raspberry Pi через C + Go + Web Dashboard.
|
||||
Реалтайм-мониторинг параллельной GPIO-шины Raspberry Pi: C + Go + Web Dashboard.
|
||||
|
||||
Проект читает входящие сигналы с GPIO-пинов Raspberry Pi по прерыванию, передаёт данные через FIFO pipe в Go-сервер и отображает всё в браузере в реальном времени.
|
||||
Проект читает входящие сигналы с GPIO-пинов Raspberry Pi по прерыванию, передаёт данные через FIFO pipe в Go-сервер и отображает их в браузере в реальном времени с возможностью просмотра исторических данных.
|
||||
|
||||
---
|
||||
|
||||
# Возможности
|
||||
## Возможности
|
||||
|
||||
✅ Захват данных по GPIO interrupt
|
||||
✅ Работа через WiringPi
|
||||
✅ FIFO pipe между C и Go
|
||||
✅ Высокоскоростной RingBuffer
|
||||
✅ HTTP API
|
||||
✅ Web Dashboard
|
||||
✅ Работа через Wi-Fi Access Point Raspberry Pi
|
||||
✅ Подходит для ПК и мобильных устройств
|
||||
✅ Захват данных по GPIO interrupt (C + WiringPi)
|
||||
✅ FIFO pipe между капчером и Go-сервером (systemd socket-activation)
|
||||
✅ Кольцевой буфер в RAM + бинарный архив на диске
|
||||
✅ HTTP API + встроенный Web Dashboard (TypeScript)
|
||||
✅ **Автоматическая ротация** лог-файлов (почасовые `.bin`)
|
||||
✅ **Retention** — автоочистка старых логов по возрасту и размеру
|
||||
✅ **Watchdog** — события при пропадании данных
|
||||
✅ **Монитор звука** с микрофона (arecord/ALSA)
|
||||
✅ **MJPEG-камера** через go2rtc
|
||||
✅ Сборка **deb-пакета** с версионированием через git
|
||||
✅ Работа через Wi-Fi Access Point
|
||||
|
||||
---
|
||||
|
||||
# Архитектура
|
||||
## Документация
|
||||
|
||||
| Документ | Содержание | Для кого |
|
||||
|---|---|---|
|
||||
| [docs/operations.md](docs/operations.md) | Установка (deb и вручную), systemd, firewall, камера, микрофон, диагностика | Администраторы |
|
||||
| [docs/architecture.md](docs/architecture.md) | Устройство системы: компоненты, поток данных, конкурентность | Разработчики |
|
||||
| [docs/api.md](docs/api.md) | Спецификация HTTP API: все эндпоинты с примерами ответов | Разработчики |
|
||||
| [docs/data-formats.md](docs/data-formats.md) | Байт GPIO, формат `.bin`, протокол pipe, события, пути хранения | Разработчики |
|
||||
| [docs/development.md](docs/development.md) | Сборка, Makefile, эмуляторы, тесты, соглашения | Разработчики |
|
||||
| [docs/frontend.md](docs/frontend.md) | Веб-дашборд: модули TypeScript, сборка, dev-цикл | Разработчики |
|
||||
| [docs/tech-debt.md](docs/tech-debt.md) | Реестр технического долга | Разработчики |
|
||||
| [SDR/README.md](SDR/README.md) | Подпроект OFDM-радиолинка на PlutoSDR (автономный) | Разработчики |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | История изменений | Все |
|
||||
|
||||
---
|
||||
|
||||
## Архитектура (кратко)
|
||||
|
||||
```text
|
||||
GPIO BUS (8 bit)
|
||||
↓ прерывание
|
||||
gpio-interrupt (C + WiringPi) ← поставляется отдельно, вне репозитория
|
||||
↓ stdout → systemd socket
|
||||
FIFO pipe (/tmp/gpio_pipe)
|
||||
↓
|
||||
gpio-interrupt.c
|
||||
↓ stdout
|
||||
systemd socket FIFO (/tmp/gpio_pipe)
|
||||
Go Server (gpio-monitor-server)
|
||||
├── RingBuffer (RAM)
|
||||
├── Data Logger (почасовые .bin)
|
||||
├── Human / Event Logger (текстовые логи)
|
||||
├── Watchdog + Retention
|
||||
├── Audio Monitor (arecord)
|
||||
└── HTTP API (:8080) + Web Dashboard (embedded)
|
||||
↓
|
||||
Go Server
|
||||
↓
|
||||
HTTP API (:8080)
|
||||
↓
|
||||
Dashboard (Browser)
|
||||
Браузер: dashboard.html / logs.html
|
||||
```
|
||||
|
||||
# Структура проекта
|
||||
HTTP API: `/api/health`, `/api/latest`, `/api/history` (последние 300), `/api/stream`, `/api/cam` (MJPEG-прокси), `/api/log/files`, `/api/log/data?file=...&page=...`, `/api/log/events?page=...` — подробности в [docs/api.md](docs/api.md).
|
||||
|
||||
Подробная схема — [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/server/main.go # Go HTTP сервер
|
||||
├── internal/adapter/ # RingBuffer
|
||||
├── web/
|
||||
│ ├── index.html
|
||||
│ ├── dashboard.html
|
||||
│ ├── css/style.css
|
||||
│ ├── js/app.js
|
||||
│ └── favicon.png
|
||||
├── Makefile # Сборка (основной способ)
|
||||
├── build.sh # Исторический скрипт сборки
|
||||
├── start-server.sh # Скрипт запуска на устройстве
|
||||
├── build/ # Артефакты сборки (генерируется)
|
||||
├── cmd/server/
|
||||
│ ├── main.go # Go HTTP-сервер (точка входа)
|
||||
│ └── web/ # Веб-интерфейс
|
||||
│ ├── js/ # TypeScript-исходники (10 модулей)
|
||||
│ ├── dist/ # Скомпилированный JS (генерируется tsc)
|
||||
│ ├── css/, fonts/
|
||||
│ ├── index.html, dashboard.html, logs.html, favicon.png
|
||||
│ ├── package.json, tsconfig.json
|
||||
│ └── README.md
|
||||
├── internal/
|
||||
│ ├── adapter/buffer.go # Кольцевой буфер
|
||||
│ ├── audio/monitor.go # Монитор уровня звука (arecord)
|
||||
│ ├── logger/ # Система логирования
|
||||
│ │ ├── config.go # Конфигурация
|
||||
│ │ ├── data_logger.go # Бинарные логи
|
||||
│ │ ├── event_logger.go # События
|
||||
│ │ ├── human_logger.go # Человекочитаемые логи
|
||||
│ │ ├── monitor.go # Watchdog
|
||||
│ │ ├── parser.go # Разбор байта GPIO
|
||||
│ │ ├── paths.go # Пути хранения
|
||||
│ │ ├── retention.go # Очистка старых логов
|
||||
│ │ ├── retention_test.go # Тесты retention
|
||||
│ │ └── rotation.go # Почасовая ротация
|
||||
│ └── pipe/reader.go # Чтение из FIFO pipe
|
||||
├── scripts/
|
||||
├── start-server.sh
|
||||
│ ├── monitor-gpio.service # systemd: капчер GPIO
|
||||
│ ├── monitor-gpio.socket # systemd: FIFO pipe
|
||||
│ ├── go2monitor.service # systemd: Go-сервер (ручная установка)
|
||||
│ ├── go2rtc, go2rtc.yaml # Стрим камеры
|
||||
│ ├── emulator.py, array.txt # Эмулятор потока GPIO
|
||||
│ ├── imi_wire.py # Имитатор шины на реальных GPIO
|
||||
│ ├── emulatohackrf.sh # Передача данных через HackRF
|
||||
│ ├── vu_meter.py # Консольный VU-метр микрофона
|
||||
│ └── view_logs.sh # Статистика dev-логов
|
||||
├── debian/ # Сборка deb-пакета (control, unit, скрипты)
|
||||
├── SDR/ # Подпроект: OFDM-радиолинк PlutoSDR (C)
|
||||
│ ├── common.c/h, transmitter.c, reciever.c
|
||||
│ ├── makefile
|
||||
│ └── README.md
|
||||
├── docs/ # Документация (см. таблицу выше)
|
||||
├── go.mod # module gpio-monitor, Go 1.21, без зависимостей
|
||||
├── CHANGELOG.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
# GPIO pin
|
||||
Каталоги `build/`, `cmd/server/web/dist/` и `cmd/server/web/node_modules/` генерируются при сборке и в git не хранятся.
|
||||
|
||||
## GPIO-пины
|
||||
|
||||
```
|
||||
WR/STROBE = GPIO27
|
||||
|
||||
DATA BUS:
|
||||
D0 = GPIO21
|
||||
D1 = GPIO7
|
||||
D2 = GPIO6
|
||||
D3 = GPIO5
|
||||
D4 = GPIO25
|
||||
D5 = GPIO24
|
||||
D6 = GPIO23
|
||||
D7 = GPIO22
|
||||
D0 = GPIO21 D4 = GPIO25
|
||||
D1 = GPIO7 D5 = GPIO24
|
||||
D2 = GPIO6 D6 = GPIO23
|
||||
D3 = GPIO5 D7 = GPIO22
|
||||
```
|
||||
|
||||
# Установка
|
||||
---
|
||||
|
||||
sudo apt update
|
||||
sudo apt install golang git build-essential
|
||||
## Быстрый старт
|
||||
|
||||
# Сборка
|
||||
### Сборка
|
||||
|
||||
go build -o gpio-monitor-server ./cmd/server
|
||||
```bash
|
||||
sudo apt install -y golang git build-essential wiringpi nodejs npm alsa-utils
|
||||
git clone <your-repo> && cd golang
|
||||
make init # подготовка окружения (Node.js, Go-модули, npm-пакеты)
|
||||
make build # TypeScript-фронтенд + Go-сервер → build/gpio-monitor-server
|
||||
```
|
||||
|
||||
# Запуск сервера
|
||||
Подробности, кросс-компиляция, эмуляция без железа — [docs/development.md](docs/development.md).
|
||||
|
||||
./start-server.sh
|
||||
### Запуск
|
||||
|
||||
sudo systemctl start monitor-gpio.service
|
||||
```bash
|
||||
./build/gpio-monitor-server \
|
||||
-pipe /tmp/gpio_pipe \
|
||||
-retention-hours=72 \
|
||||
-retention-mb=2000
|
||||
```
|
||||
|
||||
# Dashboard
|
||||
Дашборд: `http://<IP>:8080` (через Wi-Fi AP — `http://192.168.77.1:8080`, локально — `http://localhost:8080`). Страницы: `/dashboard.html` — мониторинг, `/logs.html` — история.
|
||||
|
||||
Открывать на http://192.168.77.1:8080
|
||||
Полная таблица из 13 CLI-флагов, настройка systemd, firewall (nftables), камеры и микрофона — [docs/operations.md](docs/operations.md).
|
||||
|
||||
# API
|
||||
### Deb-пакет
|
||||
|
||||
GET /api/health
|
||||
```bash
|
||||
make deb # от обычного пользователя, без sudo
|
||||
sudo dpkg -i build/gpio-monitor-server_1.0.0-build<N>_arm64.deb
|
||||
```
|
||||
|
||||
GET /api/history
|
||||
Версия пакета формируется автоматически: `<база из debian/control>-build<число git-коммитов>`. Полезные цели: `make version` (показать версию), `make deb-info` (информация о пакете), `make deb-contents` (список файлов), `make deb-clean` (очистка). Подробнее — [docs/development.md](docs/development.md#сборка-deb-пакета); что делает пакет при установке — [docs/operations.md](docs/operations.md#установка-из-deb-пакета).
|
||||
|
||||
# Wi-Fi режим Raspberry Pi
|
||||
---
|
||||
|
||||
SSID: fix_me
|
||||
IP: 192.168.77.1
|
||||
## Хранение данных (кратко)
|
||||
|
||||
Подключение: ssh user@192.168.77.1
|
||||
Бинарные данные пишутся в почасовые файлы `gpio-YYYY-MM-DD-HH.bin` в каталоге данных (`/var/log/gpio-monitoring/data/` при deb-установке, `~/.local/share/gpio-monitoring/logs/data/` в dev-режиме). Ротация открывает новый файл каждый час; retention удаляет старые файлы по возрасту (`-retention-hours`) и суммарному размеру (`-retention-mb`), всегда сохраняя минимум 2 свежих файла.
|
||||
|
||||
# License
|
||||
Форматы файлов, словарь событий и алгоритм очистки — [docs/data-formats.md](docs/data-formats.md).
|
||||
|
||||
MIT
|
||||
---
|
||||
|
||||
## Диагностика (кратко)
|
||||
|
||||
```bash
|
||||
sudo systemctl status gpio-monitor-server # или monitor-gpio / go2monitor
|
||||
sudo ss -tlnp | grep 8080 # сервер слушает порт?
|
||||
sudo nft list ruleset | grep 8080 # firewall пропускает?
|
||||
ls -la /tmp/gpio_pipe # FIFO существует?
|
||||
gpio-logs -f # журнал сервиса (deb-установка)
|
||||
```
|
||||
|
||||
Полный чек-лист (дашборд, звук, камера, «Нет связи») — [docs/operations.md](docs/operations.md#диагностика-проблем).
|
||||
|
||||
328
SDR/README.md
Normal file
328
SDR/README.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# OFDM Radio Link — PlutoSDR
|
||||
|
||||
Система цифровой радиосвязи на базе PlutoSDR с использованием OFDM-модуляции и помехоустойчивого кодирования Рида-Соломона RS(255,223).
|
||||
|
||||
## Оглавление
|
||||
|
||||
- [Возможности](#возможности)
|
||||
- [Требования](#требования)
|
||||
- [Структура проекта](#структура-проекта)
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Сборка](#сборка)
|
||||
- [Использование](#использование)
|
||||
- [Параметры командной строки](#параметры-командной-строки)
|
||||
- [Формат кадра](#формат-кадра)
|
||||
- [Диагностика](#диагностика)
|
||||
- [Устранение неполадок](#устранение-неполадок)
|
||||
- [Статус подпроекта](#статус-подпроекта)
|
||||
|
||||
## Возможности
|
||||
|
||||
- **OFDM-модуляция**: 64 поднесущих, циклический префикс 16, сглаживающее окно 4
|
||||
- **Помехоустойчивое кодирование**: Рида-Соломона RS(255,223) с CRC32
|
||||
- **Частотная коррекция**: адаптивная компенсация CFO через NCO с экспоненциальным фильтром (α=0.95)
|
||||
- **Ручное управление усилением**: фиксированное усиление для максимальной стабильности
|
||||
- **Потоковая передача**: работа через stdin/stdout для конвейерной обработки
|
||||
- **Мониторинг в реальном времени**: RSSI, EVM, CFO, статистика кадров
|
||||
- **Гибкая настройка**: частота, полоса, усиление, амплитуда, паузы между кадрами
|
||||
|
||||
## Требования
|
||||
|
||||
### Аппаратное обеспечение
|
||||
- 2× PlutoSDR (один на передачу, один на приём) или 1× для тестирования в петле
|
||||
- Raspberry Pi (рекомендуется) или любой Linux-компьютер
|
||||
- Антенны/аттенюаторы для RF-тракта
|
||||
|
||||
### Программное обеспечение
|
||||
```bash
|
||||
# Установка зависимостей
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential python3 cmake libfftw3-dev
|
||||
|
||||
# libiio (для работы с PlutoSDR)
|
||||
sudo apt install -y libiio-dev
|
||||
|
||||
# liquid-dsp (OFDM и FEC)
|
||||
git clone https://github.com/jgaeddert/liquid-dsp.git
|
||||
cd liquid-dsp
|
||||
./bootstrap.sh
|
||||
./configure
|
||||
make -j4
|
||||
sudo make install
|
||||
sudo ldconfig
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
SDR/
|
||||
├── common.h # Общие определения и прототипы
|
||||
├── common.c # Реализация общих функций
|
||||
├── transmitter.c # Передатчик
|
||||
├── reciever.c # Приёмник с частотной коррекцией (имя файла с опечаткой — см. ниже)
|
||||
├── makefile # Система сборки
|
||||
├── README.md # Этот файл
|
||||
└── test_input.bin # Тестовый файл (создаётся make test)
|
||||
```
|
||||
|
||||
> ⚠️ **Известная проблема сборки приёмника.** `makefile` ожидает файл `receiver.c`, но в репозитории он называется `reciever.c` (опечатка в имени). Из-за проверки существования файла цель `receiver` не попадает в `make all`, и `make` собирает **только передатчик**. Пока имя не исправлено, приёмник собирается вручную:
|
||||
>
|
||||
> ```bash
|
||||
> gcc -Wall -O2 -o receiver reciever.c common.c -liio -lliquid -lfftw3f -lm -lpthread
|
||||
> ```
|
||||
>
|
||||
> Долг зафиксирован в [docs/tech-debt.md](../docs/tech-debt.md#sdr) основного проекта.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# 1. Сборка
|
||||
make
|
||||
|
||||
# 2. Создание тестового файла (1 МБ)
|
||||
make test
|
||||
|
||||
# 3. Запуск приёмника (на одном устройстве)
|
||||
./receiver -f 1265000000 -r 3840000 -b 2000000 -c rs8 > output.bin
|
||||
|
||||
# 4. Запуск передатчика (на другом устройстве)
|
||||
cat test_input.bin | ./transmitter -f 1265000000 -r 3840000 -b 2000000 -g -20 -a 0.3 -c rs8
|
||||
|
||||
# 5. Проверка результата
|
||||
md5sum test_input.bin output.bin
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
# Сборка всего проекта
|
||||
make
|
||||
|
||||
# Сборка только передатчика
|
||||
make transmitter
|
||||
|
||||
# Приёмник — вручную, пока файл называется reciever.c (см. «Известная проблема» выше)
|
||||
gcc -Wall -O2 -o receiver reciever.c common.c -liio -lliquid -lfftw3f -lm -lpthread
|
||||
|
||||
# Создание тестового файла 1 МБ
|
||||
make test
|
||||
|
||||
# Очистка
|
||||
make clean
|
||||
|
||||
# Справка
|
||||
make help
|
||||
```
|
||||
|
||||
**Флаги компиляции:**
|
||||
- `-Wall -O2` — все предупреждения и оптимизация
|
||||
- `-liio` — библиотека libiio для PlutoSDR
|
||||
- `-lliquid` — liquid-dsp (OFDM, FEC, NCO)
|
||||
- `-lfftw3f` — FFTW для преобразований Фурье
|
||||
- `-lm -lpthread` — математика и потоки
|
||||
|
||||
## Использование
|
||||
|
||||
### Передатчик
|
||||
|
||||
```bash
|
||||
# Базовая передача тестового файла
|
||||
cat test_input.bin | ./transmitter -f 1265000000 -r 3840000 -b 2000000 -c rs8
|
||||
|
||||
# Передача случайных данных
|
||||
cat /dev/urandom | ./transmitter -f 1265000000 -r 3840000 -b 2000000 -g -20 -a 0.3
|
||||
|
||||
# Высокоскоростная передача без пауз
|
||||
cat data.bin | ./transmitter -f 2400000000 -r 7680000 -b 6000000 -g 0 -a 0.5 -p 0
|
||||
|
||||
# Передача текста
|
||||
echo "Hello, OFDM World!" | ./transmitter -f 1265000000 -r 3840000 -b 2000000
|
||||
```
|
||||
|
||||
### Приёмник
|
||||
|
||||
```bash
|
||||
# Базовый приём с декодированием FEC
|
||||
./receiver -f 1265000000 -r 3840000 -b 2000000 -c rs8 > output.bin
|
||||
|
||||
# Приём без FEC (выше скорость, но нет исправления ошибок)
|
||||
./receiver -f 1265000000 -r 3840000 -b 2000000 -c none > output.bin
|
||||
|
||||
# Приём с сохранением лога статистики
|
||||
./receiver -f 1265000000 -r 3840000 -b 2000000 -c rs8 > output.bin 2> stats.log
|
||||
|
||||
# Мониторинг статистики в реальном времени
|
||||
watch -n 1 'tail -1 stats.log'
|
||||
```
|
||||
|
||||
### Тестирование на одном устройстве (петля)
|
||||
|
||||
```bash
|
||||
# Терминал 1: Приёмник
|
||||
./receiver -f 2400000000 -r 3840000 -b 2000000 -c rs8 > output.bin
|
||||
|
||||
# Терминал 2: Передатчик
|
||||
cat test_input.bin | ./transmitter -f 2400000000 -r 3840000 -b 2000000 -g -40 -c rs8
|
||||
```
|
||||
|
||||
## Параметры командной строки
|
||||
|
||||
### Общие параметры (для TX и RX)
|
||||
|
||||
| Параметр | Описание | По умолчанию |
|
||||
|----------|----------|--------------|
|
||||
| `-f ЧАСТОТА` | Несущая частота в Гц | 1265000000 (1265 МГц) |
|
||||
| `-r СКОРОСТЬ` | Частота дискретизации в Гц | 3840000 (3.84 МГц) |
|
||||
| `-b ПОЛОСА` | Полоса пропускания в Гц | 3000000 (3 МГц) |
|
||||
| `-u URI` | Адрес PlutoSDR | ip:192.168.2.1 (RX) / ip:192.168.3.1 (TX) |
|
||||
| `-c РЕЖИМ` | FEC: `rs8` или `none` | rs8 |
|
||||
| `-h` | Показать справку | — |
|
||||
|
||||
### Специфичные параметры передатчика
|
||||
|
||||
| Параметр | Описание | По умолчанию |
|
||||
|----------|----------|--------------|
|
||||
| `-g УСИЛЕНИЕ` | Усиление TX в дБ | -30.0 |
|
||||
| `-a АМПЛИТУДА` | Амплитуда сигнала (0..1) | 0.2 |
|
||||
| `-p ПАУЗА` | Пауза между кадрами в мкс | 2000 |
|
||||
|
||||
### Специфичные параметры приёмника
|
||||
|
||||
| Параметр | Значение | Примечание |
|
||||
|----------|----------|------------|
|
||||
| Усиление | 50 дБ | Фиксированное, ручной режим |
|
||||
| CFO фильтр | α=0.95 | Экспоненциальное сглаживание |
|
||||
|
||||
## Формат кадра
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OFDM КАДР │
|
||||
├──────────┬──────────────────────────────────────────────────────┤
|
||||
│ Заголовок│ Полезная нагрузка │
|
||||
│ 12 байт │ до 1024 байт │
|
||||
└──────────┴──────────────────────────────────────────────────────┘
|
||||
|
||||
Заголовок (12 байт):
|
||||
┌───────┬───────┬──────────────────┬───────────────┬───────────────┬──────────────┬──────────┐
|
||||
│ Байты │ 0-1 │ 2-5 │ 6-7 │ 8-9 │ 10 │ 11 │
|
||||
├───────┼───────┼──────────────────┼───────────────┼───────────────┼──────────────┼──────────┤
|
||||
│ Знач. │ 0xF0 │ Порядковый номер │ Длина исходных│ Кол-во блоков │ Последний │ Резерв │
|
||||
│ │ 0xAA │ кадра (32 бита) │ данных (16 бит)│ FEC (16 бит) │ блок RS │ │
|
||||
└───────┴───────┴──────────────────┴───────────────┴───────────────┴──────────────┴──────────┘
|
||||
```
|
||||
|
||||
## Диагностика
|
||||
|
||||
### Вывод приёмника в реальном времени
|
||||
|
||||
```
|
||||
Кадры | Загол. | CRC | RS испр | Вывод | RSSI | EVM | CFO
|
||||
--------+--------+------+---------+--------+-------+-------+----------
|
||||
1234 | 1200 | 1180 | 15 | 1195 | -45 | 2.3 | 0.0015
|
||||
```
|
||||
|
||||
**Расшифровка:**
|
||||
- **Кадры** — всего обнаружено кадров синхронизатором
|
||||
- **Загол.** — количество корректно декодированных заголовков
|
||||
- **CRC** — кадры с корректной контрольной суммой
|
||||
- **RS испр** — кадры, исправленные кодом Рида-Соломона
|
||||
- **Вывод** — успешно выведено кадров в stdout
|
||||
- **RSSI** — уровень сигнала в дБм (чем ближе к 0, тем лучше)
|
||||
- **EVM** — вектор ошибки в % (чем меньше, тем лучше)
|
||||
- **CFO** — оценка частотного сдвига в рад/семпл
|
||||
|
||||
### Итоговая статистика
|
||||
|
||||
```
|
||||
═══════════════════════════════════════════════════
|
||||
ПРИЁМ ЗАВЕРШЁН
|
||||
═══════════════════════════════════════════════════
|
||||
Обнаружено кадров: 1024
|
||||
Корректных заголовков: 1024 (100.0%)
|
||||
Корректных CRC: 1024 (100.0%)
|
||||
Исправлено RS: 0
|
||||
Успешно выведено: 1024 кадров
|
||||
Всего принято данных: 1048576 байт (1.00 МБ)
|
||||
Финальная оценка CFO: 0.0012 рад/семпл
|
||||
═══════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
## Устранение неполадок
|
||||
|
||||
### Ошибка подключения к PlutoSDR
|
||||
|
||||
```bash
|
||||
# Проверка подключения
|
||||
iio_info -s
|
||||
|
||||
# Пинг устройства
|
||||
ping 192.168.2.1
|
||||
|
||||
# Проверка интерфейса
|
||||
ifconfig eth1
|
||||
```
|
||||
|
||||
### Нет приёма сигнала
|
||||
|
||||
1. **Проверьте частоту** — убедитесь, что TX и RX на одной частоте
|
||||
2. **Увеличьте усиление TX** — попробуйте `-g 0` вместо `-g -30`
|
||||
3. **Уменьшите расстояние** — или используйте проводное соединение
|
||||
4. **Проверьте антенны** — убедитесь, что они подключены к правильным портам
|
||||
5. **Используйте петлю** — подключите TX напрямую к RX через аттенюатор 30-40 дБ
|
||||
|
||||
### Высокий EVM / много ошибок
|
||||
|
||||
1. **Уменьшите полосу** — `-b 1500000` вместо `-b 3000000`
|
||||
2. **Увеличьте усиление RX** — отредактируйте `RX_GAIN` в `reciever.c`
|
||||
3. **Добавьте паузы** — `-p 5000` для стабилизации
|
||||
4. **Уменьшите амплитуду TX** — `-a 0.15` для снижения искажений
|
||||
5. **Проверьте CFO** — значение > 0.1 требует настройки частоты
|
||||
|
||||
### Ошибка сегментирования (Segmentation Fault)
|
||||
|
||||
1. **Проверьте версию liquid-dsp** — требуется >= 1.3.0
|
||||
2. **Пересоберите с отладкой**:
|
||||
```bash
|
||||
gcc -g -O0 -o receiver_debug reciever.c common.c -liio -lliquid -lfftw3f -lm -lpthread
|
||||
gdb ./receiver_debug
|
||||
run -f 1265000000 -r 3840000 -b 2000000 -c rs8
|
||||
bt
|
||||
```
|
||||
3. **Проверьте права доступа к USB**:
|
||||
```bash
|
||||
sudo chmod 666 /dev/iio*
|
||||
```
|
||||
|
||||
### Предупреждения компиляции
|
||||
|
||||
Предупреждения типа `-Wformat` исправлены добавлением суффикса `LL` к константам:
|
||||
```c
|
||||
#define DEFAULT_FREQ 1265000000LL // было 1265000000
|
||||
```
|
||||
|
||||
## Оптимальные параметры для разных сценариев
|
||||
|
||||
### Максимальная скорость
|
||||
```bash
|
||||
# TX
|
||||
cat data.bin | ./transmitter -f 2400000000 -r 7680000 -b 6000000 -g 0 -a 0.5 -p 0
|
||||
|
||||
# RX
|
||||
./receiver -f 2400000000 -r 7680000 -b 6000000 -c none > output.bin
|
||||
```
|
||||
|
||||
### Максимальная надёжность
|
||||
```bash
|
||||
# TX
|
||||
cat data.bin | ./transmitter -f 1265000000 -r 3840000 -b 1500000 -g -10 -a 0.4 -p 5000 -c rs8
|
||||
|
||||
# RX
|
||||
./receiver -f 1265000000 -r 3840000 -b 1500000 -c rs8 > output.bin
|
||||
```
|
||||
|
||||
|
||||
## Статус подпроекта
|
||||
|
||||
SDR — автономный подпроект в репозитории GPIO Monitor: со своей сборкой, без связи с Go-сервером. Радиоканал используется как альтернативный транспорт байтов GPIO-шины (см. [scripts/emulatohackrf.sh](../scripts/emulatohackrf.sh) — передача потока эмулятора через HackRF). Общая документация основного проекта — в [docs/](../docs/).
|
||||
|
||||
106
SDR/common.c
Normal file
106
SDR/common.c
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* common.c — Реализация общих функций
|
||||
*/
|
||||
|
||||
#include "common.h"
|
||||
|
||||
volatile sig_atomic_t stop = 0;
|
||||
|
||||
static void sigint_handler(int s) {
|
||||
(void)s;
|
||||
stop = 1;
|
||||
}
|
||||
|
||||
void setup_signal_handlers(void) {
|
||||
signal(SIGINT, sigint_handler);
|
||||
signal(SIGTERM, sigint_handler);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
}
|
||||
|
||||
void print_usage(const char *prog_name, const char *mode) {
|
||||
fprintf(stderr, "═══════════════════════════════════════════════════\n");
|
||||
fprintf(stderr, "OFDM %s — PlutoSDR Radio Link\n", mode);
|
||||
fprintf(stderr, "═══════════════════════════════════════════════════\n");
|
||||
fprintf(stderr, "Использование: %s [ОПЦИИ]\n\n", prog_name);
|
||||
fprintf(stderr, "ОБЩИЕ ОПЦИИ:\n");
|
||||
fprintf(stderr, " -f ЧАСТОТА Частота несущей в Гц (по умолчанию: %lld)\n", DEFAULT_FREQ);
|
||||
fprintf(stderr, " -r СКОРОСТЬ Частота дискретизации (по умолчанию: %lld)\n", DEFAULT_RATE);
|
||||
fprintf(stderr, " -b ПОЛОСА Полоса пропускания (по умолчанию: %lld)\n", DEFAULT_BW);
|
||||
fprintf(stderr, " -u URI URI PlutoSDR\n");
|
||||
fprintf(stderr, " -c РЕЖИМ FEC: rs8 или none (по умолчанию: rs8)\n");
|
||||
fprintf(stderr, " -h Показать эту справку\n");
|
||||
}
|
||||
|
||||
void print_config(const char *mode, long long freq, long long rate,
|
||||
long long bw, int fec_on) {
|
||||
fprintf(stderr, "═══════════════════════════════════════════════════\n");
|
||||
fprintf(stderr, "КОНФИГУРАЦИЯ %s\n", mode);
|
||||
fprintf(stderr, "Частота: %.1f МГц\n", freq / 1e6);
|
||||
fprintf(stderr, "Дискрет.: %.2f МГц\n", rate / 1e6);
|
||||
fprintf(stderr, "Полоса: %.2f МГц\n", bw / 1e6);
|
||||
fprintf(stderr, "OFDM: M=%d, CP=%d, Taper=%d\n", OFDM_M, OFDM_CP, OFDM_TAPER);
|
||||
fprintf(stderr, "FEC: %s\n", fec_on ? "RS(255,223)" : "ОТКЛЮЧЕН");
|
||||
}
|
||||
|
||||
int pluto_init(const char *uri, struct iio_context **ctx,
|
||||
struct iio_device **phy, struct iio_device **dev, int is_tx) {
|
||||
*ctx = iio_create_context_from_uri(uri);
|
||||
if (!*ctx) {
|
||||
LOG_ERROR("Не удалось подключиться к PlutoSDR по адресу %s", uri);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*phy = iio_context_find_device(*ctx, "ad9361-phy");
|
||||
*dev = iio_context_find_device(*ctx,
|
||||
is_tx ? "cf-ad9361-dds-core-lpc" : "cf-ad9361-lpc");
|
||||
|
||||
if (!*phy || !*dev) {
|
||||
LOG_ERROR("Радиоустройства не найдены в контексте");
|
||||
iio_context_destroy(*ctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void pluto_configure(struct iio_device *phy, long long freq, long long rate,
|
||||
long long bw, double gain, int is_tx) {
|
||||
if (is_tx) {
|
||||
// Настройка передатчика
|
||||
iio_channel_attr_write_longlong(
|
||||
iio_device_find_channel(phy, "altvoltage1", true),
|
||||
"frequency", freq);
|
||||
iio_channel_attr_write_longlong(
|
||||
iio_device_find_channel(phy, "voltage0", true),
|
||||
"sampling_frequency", rate);
|
||||
iio_channel_attr_write_longlong(
|
||||
iio_device_find_channel(phy, "voltage0", true),
|
||||
"rf_bandwidth", bw);
|
||||
iio_channel_attr_write(
|
||||
iio_device_find_channel(phy, "voltage0", true),
|
||||
"rf_port_select", "A");
|
||||
iio_channel_attr_write_double(
|
||||
iio_device_find_channel(phy, "voltage0", true),
|
||||
"hardwaregain", gain);
|
||||
} else {
|
||||
// Настройка приёмника
|
||||
iio_channel_attr_write_longlong(
|
||||
iio_device_find_channel(phy, "altvoltage0", true),
|
||||
"frequency", freq);
|
||||
iio_channel_attr_write_longlong(
|
||||
iio_device_find_channel(phy, "voltage0", false),
|
||||
"sampling_frequency", rate);
|
||||
iio_channel_attr_write_longlong(
|
||||
iio_device_find_channel(phy, "voltage0", false),
|
||||
"rf_bandwidth", bw);
|
||||
iio_channel_attr_write(
|
||||
iio_device_find_channel(phy, "voltage0", false),
|
||||
"gain_control_mode", "manual");
|
||||
iio_channel_attr_write_double(
|
||||
iio_device_find_channel(phy, "voltage0", false),
|
||||
"hardwaregain", gain);
|
||||
iio_channel_attr_write(
|
||||
iio_device_find_channel(phy, "voltage0", false),
|
||||
"rf_port_select", "A_BALANCED");
|
||||
}
|
||||
}
|
||||
61
SDR/common.h
Normal file
61
SDR/common.h
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* common.h — Общие определения и конфигурация для приёмника и передатчика
|
||||
* OFDM + RS(255,223) FEC + PlutoSDR
|
||||
*/
|
||||
|
||||
#ifndef COMMON_H
|
||||
#define COMMON_H
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <signal.h>
|
||||
#include <getopt.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <iio.h>
|
||||
#include <liquid/liquid.h>
|
||||
|
||||
// Параметры OFDM-модуляции
|
||||
#define OFDM_M 64
|
||||
#define OFDM_CP 16
|
||||
#define OFDM_TAPER 4
|
||||
|
||||
// Параметры Рида-Соломона
|
||||
#define RS_DATA 223
|
||||
#define RS_ENC 255
|
||||
|
||||
// Параметры радиотракта по умолчанию
|
||||
#define DEFAULT_FREQ 1265000000LL
|
||||
#define DEFAULT_RATE 3840000LL
|
||||
#define DEFAULT_BW 3000000LL
|
||||
|
||||
// Адреса PlutoSDR
|
||||
#define TX_URI_DEFAULT "ip:192.168.3.1" // адрес Pluto передатчика
|
||||
#define RX_URI_DEFAULT "ip:192.168.2.1" // адрес Pluto приемника
|
||||
|
||||
// Формат заголовка кадра
|
||||
#define HDR_SIGNATURE_0 0xF0
|
||||
#define HDR_SIGNATURE_1 0xAA
|
||||
#define HDR_SIZE 12
|
||||
|
||||
// Макросы логирования
|
||||
#define LOG_ERROR(fmt, ...) fprintf(stderr, "[ОШИБКА] " fmt "\n", ##__VA_ARGS__)
|
||||
#define LOG_INFO(fmt, ...) fprintf(stderr, "[INFO] " fmt "\n", ##__VA_ARGS__)
|
||||
|
||||
// Глобальный флаг завершения
|
||||
extern volatile sig_atomic_t stop;
|
||||
|
||||
// Прототипы общих функций
|
||||
void setup_signal_handlers(void);
|
||||
void print_usage(const char *prog_name, const char *mode);
|
||||
void print_config(const char *mode, long long freq, long long rate,
|
||||
long long bw, int fec_on);
|
||||
int pluto_init(const char *uri, struct iio_context **ctx,
|
||||
struct iio_device **phy, struct iio_device **dev, int is_tx);
|
||||
void pluto_configure(struct iio_device *phy, long long freq, long long rate,
|
||||
long long bw, double gain, int is_tx);
|
||||
|
||||
#endif
|
||||
47
SDR/makefile
Normal file
47
SDR/makefile
Normal file
@@ -0,0 +1,47 @@
|
||||
# Makefile для сборки OFDM-приёмника и передатчика для PlutoSDR
|
||||
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -O2
|
||||
LDFLAGS = -liio -lliquid -lfftw3f -lm -lpthread
|
||||
|
||||
TX_BIN = transmitter
|
||||
RX_BIN = receiver
|
||||
TX_SRC = transmitter.c
|
||||
RX_SRC = receiver.c
|
||||
|
||||
TX_EXISTS := $(shell test -f $(TX_SRC) && echo 1 || echo 0)
|
||||
RX_EXISTS := $(shell test -f $(RX_SRC) && echo 1 || echo 0)
|
||||
|
||||
ifeq ($(TX_EXISTS),1)
|
||||
TARGETS += $(TX_BIN)
|
||||
endif
|
||||
|
||||
ifeq ($(RX_EXISTS),1)
|
||||
TARGETS += $(RX_BIN)
|
||||
endif
|
||||
|
||||
all: $(TARGETS)
|
||||
@echo "Готово: $(TARGETS)"
|
||||
|
||||
$(TX_BIN): $(TX_SRC) common.c common.h
|
||||
$(CC) $(CFLAGS) -o $@ $(TX_SRC) common.c $(LDFLAGS)
|
||||
@echo " ✓ $(TX_BIN) собран"
|
||||
|
||||
$(RX_BIN): $(RX_SRC) common.c common.h
|
||||
$(CC) $(CFLAGS) -o $@ $(RX_SRC) common.c $(LDFLAGS)
|
||||
@echo " ✓ $(RX_BIN) собран"
|
||||
|
||||
test:
|
||||
python3 -c "import struct; f=open('test_input.bin','wb'); [f.write(struct.pack('B', i%256)) for i in range(1048576)]; f.close()"
|
||||
@ls -lh test_input.bin
|
||||
|
||||
clean:
|
||||
rm -f $(TX_BIN) $(RX_BIN) *.o test_input.bin output.bin
|
||||
|
||||
help:
|
||||
@echo "Цели:"
|
||||
@echo " make — сборка всего, что есть"
|
||||
@echo " make test — создать тестовый файл 1 МБ"
|
||||
@echo " make clean — удалить всё собранное"
|
||||
|
||||
.PHONY: all test clean help
|
||||
206
SDR/reciever.c
Normal file
206
SDR/reciever.c
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* receiver.c — OFDM-приёмник с RS(255,223) FEC и частотной коррекцией
|
||||
*/
|
||||
|
||||
#include "common.h"
|
||||
|
||||
#define BUF_SAMPLES 32768
|
||||
#define MAX_FRAME_SAMPLES (1 << 16)
|
||||
#define RX_GAIN 50.0
|
||||
#define CFO_FILTER_ALPHA 0.95f
|
||||
#define CFO_MAX_VALID 0.5f
|
||||
|
||||
extern volatile sig_atomic_t stop;
|
||||
|
||||
static uint64_t calls = 0, hdr_ok = 0, crc_raw = 0;
|
||||
static uint64_t rs_saved = 0, crc_final = 0, total_bytes = 0;
|
||||
static float rssi = -100, evm = 0;
|
||||
static float cfo_est = 0.0f;
|
||||
static nco_crcf nco = NULL;
|
||||
static fec decoder = NULL;
|
||||
static int fec_on = 0;
|
||||
static liquid_float_complex frame[MAX_FRAME_SAMPLES];
|
||||
|
||||
static int callback(unsigned char *hdr, int hdr_valid,
|
||||
unsigned char *pay, unsigned int pay_len,
|
||||
int pay_valid, framesyncstats_s stats,
|
||||
void *user) {
|
||||
(void)user;
|
||||
calls++;
|
||||
rssi = stats.rssi;
|
||||
evm = stats.evm;
|
||||
|
||||
// Обновляем CFO только если NCO инициализирован
|
||||
if (nco && fabsf(stats.cfo) < CFO_MAX_VALID) {
|
||||
cfo_est = CFO_FILTER_ALPHA * cfo_est + (1.0f - CFO_FILTER_ALPHA) * stats.cfo;
|
||||
nco_crcf_set_frequency(nco, cfo_est);
|
||||
}
|
||||
|
||||
if (!hdr_valid) return 0;
|
||||
hdr_ok++;
|
||||
if (pay_valid) crc_raw++;
|
||||
|
||||
uint16_t original_len = (hdr[6] << 8) | hdr[7];
|
||||
uint8_t last_block_bytes = hdr[10];
|
||||
|
||||
if (fec_on && decoder && pay_len >= RS_ENC) {
|
||||
size_t nblocks = pay_len / RS_ENC;
|
||||
if (nblocks == 0 || nblocks > 100) return 0;
|
||||
|
||||
uint8_t recovered[4096];
|
||||
int rs_ok = 1;
|
||||
for (size_t b = 0; b < nblocks; b++) {
|
||||
if (fec_decode(decoder, RS_DATA, pay + b * RS_ENC,
|
||||
recovered + b * RS_DATA) != 0) {
|
||||
rs_ok = 0; break;
|
||||
}
|
||||
}
|
||||
if (rs_ok) {
|
||||
if (!pay_valid) rs_saved++;
|
||||
size_t out_len = (nblocks - 1) * RS_DATA +
|
||||
(last_block_bytes > 0 ? last_block_bytes : RS_DATA);
|
||||
if (out_len > nblocks * RS_DATA) out_len = nblocks * RS_DATA;
|
||||
if (out_len > original_len) out_len = original_len;
|
||||
if (out_len > 0) {
|
||||
fwrite(recovered, 1, out_len, stdout);
|
||||
fflush(stdout);
|
||||
total_bytes += out_len;
|
||||
crc_final++;
|
||||
}
|
||||
}
|
||||
} else if (pay_valid && original_len > 0 && original_len <= pay_len) {
|
||||
fwrite(pay, 1, original_len, stdout);
|
||||
fflush(stdout);
|
||||
total_bytes += original_len;
|
||||
crc_final++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
long long freq = DEFAULT_FREQ, rate = DEFAULT_RATE, bw = DEFAULT_BW;
|
||||
const char *uri = RX_URI_DEFAULT;
|
||||
const char *fec_opt = "rs8";
|
||||
|
||||
int opt;
|
||||
while ((opt = getopt(argc, argv, "f:r:b:u:c:h")) != -1) {
|
||||
switch (opt) {
|
||||
case 'f': freq = atoll(optarg); break;
|
||||
case 'r': rate = atoll(optarg); break;
|
||||
case 'b': bw = atoll(optarg); break;
|
||||
case 'u': uri = optarg; break;
|
||||
case 'c': fec_opt = optarg; break;
|
||||
case 'h':
|
||||
print_usage(argv[0], "ПРИЁМНИК");
|
||||
fprintf(stderr, "\nСПЕЦИФИЧНЫЕ ОПЦИИ RX:\n");
|
||||
fprintf(stderr, " Усиление фиксированное: %.0f дБ\n", RX_GAIN);
|
||||
fprintf(stderr, " Частотная коррекция: адаптивная (α=%.2f)\n", CFO_FILTER_ALPHA);
|
||||
fprintf(stderr, "\nОПТИМАЛЬНЫЙ ЗАПУСК:\n");
|
||||
fprintf(stderr, " %s -f %lld -r %lld -b %lld -c rs8 > output.bin\n",
|
||||
argv[0], DEFAULT_FREQ, DEFAULT_RATE, DEFAULT_BW);
|
||||
return 0;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
fec_on = !strcmp(fec_opt, "rs8");
|
||||
setup_signal_handlers();
|
||||
|
||||
print_config("ПРИЁМНИК", freq, rate, bw, fec_on);
|
||||
fprintf(stderr, "Усиление: %.0f дБ (ручное)\n", RX_GAIN);
|
||||
fprintf(stderr, "CFO фильтр: α=%.2f\n", CFO_FILTER_ALPHA);
|
||||
fprintf(stderr, "URI: %s\n", uri);
|
||||
fprintf(stderr, "═══════════════════════════════════════════════════\n");
|
||||
|
||||
struct iio_context *ctx = NULL;
|
||||
struct iio_device *phy = NULL, *rx = NULL;
|
||||
if (pluto_init(uri, &ctx, &phy, &rx, 0) != 0) return 1;
|
||||
|
||||
pluto_configure(phy, freq, rate, bw, RX_GAIN, 0);
|
||||
|
||||
iio_channel_enable(iio_device_find_channel(rx, "voltage0", false));
|
||||
iio_channel_enable(iio_device_find_channel(rx, "voltage1", false));
|
||||
|
||||
struct iio_buffer *buf = iio_device_create_buffer(rx, BUF_SAMPLES, false);
|
||||
if (!buf) {
|
||||
fprintf(stderr, "Ошибка создания буфера приёма\n");
|
||||
iio_context_destroy(ctx);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Инициализация FEC
|
||||
if (fec_on) {
|
||||
decoder = fec_create(LIQUID_FEC_RS_M8, NULL);
|
||||
if (!decoder) {
|
||||
fprintf(stderr, "Ошибка создания FEC-декодера\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация NCO для частотной коррекции
|
||||
nco = nco_crcf_create(LIQUID_NCO);
|
||||
if (!nco) {
|
||||
fprintf(stderr, "Ошибка создания NCO\n");
|
||||
} else {
|
||||
nco_crcf_set_frequency(nco, 0.0f);
|
||||
}
|
||||
|
||||
ofdmflexframesync fs = ofdmflexframesync_create(
|
||||
OFDM_M, OFDM_CP, OFDM_TAPER, NULL, callback, NULL);
|
||||
if (!fs) {
|
||||
fprintf(stderr, "Ошибка создания OFDM-синхронизатора\n");
|
||||
if (decoder) fec_destroy(decoder);
|
||||
if (nco) nco_crcf_destroy(nco);
|
||||
iio_buffer_destroy(buf);
|
||||
iio_context_destroy(ctx);
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "Приёмник готов. Ожидание сигнала...\n\n");
|
||||
fprintf(stderr, "Кадры | Загол. | CRC | RS испр | Вывод | RSSI | EVM | CFO\n");
|
||||
fprintf(stderr, "--------+--------+------+---------+--------+-------+-------+----------\n");
|
||||
|
||||
time_t last = time(NULL);
|
||||
|
||||
while (!stop) {
|
||||
ssize_t n = iio_buffer_refill(buf);
|
||||
if (n < 0) { usleep(500); continue; }
|
||||
|
||||
int16_t *bb = (int16_t *)iio_buffer_start(buf);
|
||||
int ns = (iio_buffer_end(buf) - iio_buffer_start(buf)) / sizeof(int16_t) / 2;
|
||||
if (ns > MAX_FRAME_SAMPLES) ns = MAX_FRAME_SAMPLES;
|
||||
|
||||
for (int i = 0; i < ns; i++) {
|
||||
frame[i] = ((float)bb[2*i] / 2048.0f) +
|
||||
((float)bb[2*i+1] / 2048.0f) * _Complex_I;
|
||||
if (nco) {
|
||||
nco_crcf_mix_down(nco, frame[i], &frame[i]);
|
||||
nco_crcf_step(nco);
|
||||
}
|
||||
}
|
||||
if (nco) nco_crcf_set_phase(nco, 0.0f);
|
||||
|
||||
ofdmflexframesync_execute(fs, frame, ns);
|
||||
|
||||
if (time(NULL) > last) {
|
||||
fprintf(stderr, "\r%6llu | %5llu | %4llu | %6llu | %6llu | %+5.0f | %+5.1f | %+8.4f",
|
||||
(unsigned long long)calls, (unsigned long long)hdr_ok,
|
||||
(unsigned long long)crc_raw, (unsigned long long)rs_saved,
|
||||
(unsigned long long)crc_final, rssi, evm, cfo_est);
|
||||
fflush(stderr);
|
||||
last = time(NULL);
|
||||
}
|
||||
}
|
||||
|
||||
fprintf(stderr, "\n\nПРИЁМ ЗАВЕРШЁН\n");
|
||||
fprintf(stderr, "Кадров: %llu | Заголовков: %llu | CRC: %llu | RS испр: %llu | Вывод: %llu | Байт: %llu\n",
|
||||
(unsigned long long)calls, (unsigned long long)hdr_ok,
|
||||
(unsigned long long)crc_raw, (unsigned long long)rs_saved,
|
||||
(unsigned long long)crc_final, (unsigned long long)total_bytes);
|
||||
|
||||
if (decoder) fec_destroy(decoder);
|
||||
if (nco) nco_crcf_destroy(nco);
|
||||
ofdmflexframesync_destroy(fs);
|
||||
iio_buffer_destroy(buf);
|
||||
iio_context_destroy(ctx);
|
||||
return 0;
|
||||
}
|
||||
185
SDR/transmitter.c
Normal file
185
SDR/transmitter.c
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* transmitter.c — OFDM-передатчик с RS(255,223) FEC для PlutoSDR
|
||||
*
|
||||
* ОПТИМАЛЬНАЯ КОМАНДА ЗАПУСКА:
|
||||
* cat test.bin | ./transmitter -f 1265000000 -r 3840000 -b 2000000 -g -20 -a 0.3 -p 0 -c rs8
|
||||
*/
|
||||
|
||||
#include "common.h"
|
||||
|
||||
// Параметры передатчика по умолчанию
|
||||
#define DEFAULT_GAIN -30.0 // Усиление передатчика: -30 дБ
|
||||
#define DEFAULT_AMP 0.2f // Амплитуда сигнала
|
||||
#define DEFAULT_PAUSE 2000 // Пауза между кадрами: 2000 мкс
|
||||
#define BUF_SAMPLES 16384 // Размер буфера в отсчётах
|
||||
#define MAX_PAYLOAD 1024 // Максимальный размер полезной нагрузки
|
||||
|
||||
// Внешняя переменная флага завершения (определена в common.c)
|
||||
extern volatile sig_atomic_t stop;
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
// Инициализация параметров
|
||||
long long freq = DEFAULT_FREQ, rate = DEFAULT_RATE, bw = DEFAULT_BW;
|
||||
double gain = DEFAULT_GAIN;
|
||||
float amp = DEFAULT_AMP;
|
||||
const char *uri = TX_URI_DEFAULT;
|
||||
int pause_us = DEFAULT_PAUSE, use_fec = 1;
|
||||
|
||||
// Обработка аргументов командной строки
|
||||
int opt;
|
||||
while ((opt = getopt(argc, argv, "f:r:b:g:a:p:u:c:h")) != -1) {
|
||||
switch (opt) {
|
||||
case 'f': freq = atoll(optarg); break;
|
||||
case 'r': rate = atoll(optarg); break;
|
||||
case 'b': bw = atoll(optarg); break;
|
||||
case 'g': gain = atof(optarg); break;
|
||||
case 'a': amp = atof(optarg); break;
|
||||
case 'p': pause_us = atoi(optarg); break;
|
||||
case 'u': uri = optarg; break;
|
||||
case 'c': use_fec = strcmp(optarg, "none") ? 1 : 0; break;
|
||||
case 'h':
|
||||
print_usage(argv[0], "ПЕРЕДАТЧИК");
|
||||
fprintf(stderr, "\nСПЕЦИФИЧНЫЕ ОПЦИИ TX:\n");
|
||||
fprintf(stderr, " -g УСИЛЕНИЕ Усиление в дБ (по умолчанию: %.0f)\n", DEFAULT_GAIN);
|
||||
fprintf(stderr, " -a АМПЛИТ. Амплитуда сигнала (по умолчанию: %.2f)\n", DEFAULT_AMP);
|
||||
fprintf(stderr, " -p ПАУЗА Пауза между кадрами в мкс (по умолчанию: %d)\n", DEFAULT_PAUSE);
|
||||
fprintf(stderr, "\nОПТИМАЛЬНЫЙ ЗАПУСК:\n");
|
||||
fprintf(stderr, " cat test.bin | %s -f %lld -r %lld -b %lld -c rs8 > /dev/null\n",
|
||||
argv[0], DEFAULT_FREQ, DEFAULT_RATE, DEFAULT_BW);
|
||||
return 0;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Настройка обработчиков сигналов
|
||||
setup_signal_handlers();
|
||||
|
||||
// Вывод конфигурации
|
||||
print_config("ПЕРЕДАТЧИК", freq, rate, bw, use_fec);
|
||||
fprintf(stderr, "Усиление: %.0f дБ\n", gain);
|
||||
fprintf(stderr, "Амплитуда: %.2f\n", amp);
|
||||
fprintf(stderr, "Пауза: %d мкс\n", pause_us);
|
||||
fprintf(stderr, "URI: %s\n", uri);
|
||||
fprintf(stderr, "═══════════════════════════════════════════════════\n");
|
||||
|
||||
// Инициализация PlutoSDR
|
||||
struct iio_context *ctx = NULL;
|
||||
struct iio_device *phy = NULL, *tx = NULL;
|
||||
if (pluto_init(uri, &ctx, &phy, &tx, 1) != 0) return 1;
|
||||
|
||||
// Настройка радиотракта
|
||||
pluto_configure(phy, freq, rate, bw, gain, 1);
|
||||
|
||||
// Включение каналов передатчика
|
||||
iio_channel_enable(iio_device_find_channel(tx, "voltage0", true));
|
||||
iio_channel_enable(iio_device_find_channel(tx, "voltage1", true));
|
||||
|
||||
// Создание буфера передачи
|
||||
struct iio_buffer *buf = iio_device_create_buffer(tx, BUF_SAMPLES, false);
|
||||
if (!buf) {
|
||||
LOG_ERROR("Ошибка создания буфера передачи");
|
||||
iio_context_destroy(ctx);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Инициализация FEC и OFDM
|
||||
fec enc = use_fec ? fec_create(LIQUID_FEC_RS_M8, NULL) : NULL;
|
||||
|
||||
ofdmflexframegenprops_s fgp;
|
||||
ofdmflexframegenprops_init_default(&fgp);
|
||||
fgp.check = LIQUID_CRC_32;
|
||||
ofdmflexframegen fg = ofdmflexframegen_create(OFDM_M, OFDM_CP, OFDM_TAPER, NULL, &fgp);
|
||||
|
||||
// Счётчики статистики
|
||||
uint64_t frames = 0, bytes_tx = 0;
|
||||
uint32_t seq = 0;
|
||||
time_t last = time(NULL);
|
||||
|
||||
fprintf(stderr, " Передатчик готов. Ожидание данных...\n\n");
|
||||
|
||||
// Основной цикл передачи
|
||||
while (!stop) {
|
||||
uint8_t raw[MAX_PAYLOAD];
|
||||
size_t n = fread(raw, 1, MAX_PAYLOAD, stdin);
|
||||
if (!n) {
|
||||
if (feof(stdin) || stop) break;
|
||||
usleep(10000);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint8_t *tx_payload = NULL;
|
||||
size_t tx_len = 0;
|
||||
uint8_t hdr[HDR_SIZE] = {0};
|
||||
|
||||
// Кодирование данных
|
||||
if (enc) {
|
||||
size_t total_blocks = (n + RS_DATA - 1) / RS_DATA;
|
||||
tx_len = total_blocks * RS_ENC;
|
||||
tx_payload = malloc(tx_len);
|
||||
|
||||
for (size_t b = 0; b < total_blocks; b++) {
|
||||
uint8_t blk[RS_DATA] = {0};
|
||||
size_t bytes = (b == total_blocks - 1) ? n - b * RS_DATA : RS_DATA;
|
||||
memcpy(blk, raw + b * RS_DATA, bytes);
|
||||
fec_encode(enc, RS_DATA, blk, tx_payload + b * RS_ENC);
|
||||
}
|
||||
|
||||
// Формирование заголовка
|
||||
hdr[0] = HDR_SIGNATURE_0; hdr[1] = HDR_SIGNATURE_1;
|
||||
hdr[2] = (seq >> 24) & 0xFF; hdr[3] = (seq >> 16) & 0xFF;
|
||||
hdr[4] = (seq >> 8) & 0xFF; hdr[5] = seq & 0xFF;
|
||||
hdr[6] = (n >> 8) & 0xFF; hdr[7] = n & 0xFF;
|
||||
hdr[8] = (total_blocks >> 8) & 0xFF; hdr[9] = total_blocks & 0xFF;
|
||||
hdr[10] = n % RS_DATA; hdr[11] = 0;
|
||||
} else {
|
||||
tx_len = n;
|
||||
tx_payload = malloc(tx_len);
|
||||
memcpy(tx_payload, raw, n);
|
||||
|
||||
hdr[0] = HDR_SIGNATURE_0; hdr[1] = HDR_SIGNATURE_1;
|
||||
hdr[2] = (seq >> 24) & 0xFF; hdr[3] = (seq >> 16) & 0xFF;
|
||||
hdr[4] = (seq >> 8) & 0xFF; hdr[5] = seq & 0xFF;
|
||||
hdr[6] = (n >> 8) & 0xFF; hdr[7] = n & 0xFF;
|
||||
}
|
||||
|
||||
// Сборка OFDM-кадра
|
||||
ofdmflexframegen_assemble(fg, hdr, tx_payload, tx_len);
|
||||
free(tx_payload);
|
||||
|
||||
// Формирование выходного сигнала
|
||||
int16_t *out = (int16_t *)iio_buffer_start(buf);
|
||||
memset(out, 0, BUF_SAMPLES * 2 * sizeof(int16_t));
|
||||
int idx = 0, done = 0;
|
||||
float complex symb[OFDM_M + OFDM_CP];
|
||||
|
||||
while (!done && (idx + OFDM_M + OFDM_CP) <= BUF_SAMPLES) {
|
||||
done = ofdmflexframegen_write(fg, symb, OFDM_M + OFDM_CP);
|
||||
for (int i = 0; i < OFDM_M + OFDM_CP; i++) {
|
||||
out[2 * idx] = (int16_t)(crealf(symb[i]) * 32767 * amp);
|
||||
out[2 * idx + 1] = (int16_t)(cimagf(symb[i]) * 32767 * amp);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (idx > 0) iio_buffer_push(buf);
|
||||
|
||||
// Обновление статистики
|
||||
frames++; bytes_tx += n; seq++;
|
||||
if (time(NULL) > last) {
|
||||
fprintf(stderr, "\r📤 Передано: %llu кадров, %llu байт",
|
||||
(unsigned long long)frames, (unsigned long long)bytes_tx);
|
||||
fflush(stderr);
|
||||
last = time(NULL);
|
||||
}
|
||||
if (pause_us > 0) usleep(pause_us);
|
||||
}
|
||||
|
||||
fprintf(stderr, "\nПередача завершена: %llu кадров, %llu байт\n",
|
||||
(unsigned long long)frames, (unsigned long long)bytes_tx);
|
||||
|
||||
// Освобождение ресурсов
|
||||
if (enc) fec_destroy(enc);
|
||||
ofdmflexframegen_destroy(fg);
|
||||
iio_buffer_destroy(buf);
|
||||
iio_context_destroy(ctx);
|
||||
return 0;
|
||||
}
|
||||
75
build.sh
75
build.sh
@@ -1,21 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -e # Остановка при любой ошибке
|
||||
|
||||
APP_NAME="gpio-monitor-server"
|
||||
CMD_PATH="./cmd/server"
|
||||
BUILD_DIR="./build"
|
||||
|
||||
echo ">> Cleaning old build..."
|
||||
echo "СБОРКА GPIO MONITOR SERVER"
|
||||
|
||||
# ===== 1. СБОРКА TYPESCRIPT =====
|
||||
echo ""
|
||||
echo "Шаг 1/4: Сборка TypeScript фронтенда..."
|
||||
cd ~/temp/golang/cmd/server/web
|
||||
|
||||
# Проверяем наличие node_modules
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo " Установка npm зависимостей..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# Собираем TypeScript
|
||||
echo " Компиляция TypeScript в JavaScript..."
|
||||
npm run build
|
||||
|
||||
if [ -d "dist" ]; then
|
||||
echo " TypeScript скомпилирован успешно!"
|
||||
echo " Скомпилированные файлы: $(ls dist/ | wc -l) файлов"
|
||||
else
|
||||
echo " Ошибка: папка dist не создана!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ===== 2. ПОДГОТОВКА GO СБОРКИ =====
|
||||
echo ""
|
||||
echo "Шаг 2/4: Подготовка Go сборки..."
|
||||
cd ~/temp/golang
|
||||
|
||||
echo " Очистка старой сборки..."
|
||||
rm -rf ${BUILD_DIR}
|
||||
mkdir -p ${BUILD_DIR}
|
||||
|
||||
echo ">> Downloading dependencies..."
|
||||
echo " Загрузка зависимостей..."
|
||||
go mod tidy
|
||||
|
||||
echo ">> Building ${APP_NAME}..."
|
||||
# ===== 3. КОМПИЛЯЦИЯ GO =====
|
||||
echo ""
|
||||
echo "Шаг 3/4: Компиляция Go сервера..."
|
||||
|
||||
# Для Raspberry Pi (ARM64)
|
||||
echo " Целевая платформа: Linux ARM64"
|
||||
GOOS=linux GOARCH=arm64 go build -o ${BUILD_DIR}/${APP_NAME} ${CMD_PATH}
|
||||
|
||||
echo ">> Build complete:"
|
||||
ls -lh ${BUILD_DIR}/${APP_NAME}
|
||||
# Проверяем успешность сборки
|
||||
if [ -f "${BUILD_DIR}/${APP_NAME}" ]; then
|
||||
echo " Go сервер скомпилирован успешно!"
|
||||
else
|
||||
echo " Ошибка компиляции Go сервера!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ===== 4. ФИНАЛЬНЫЕ ДЕЙСТВИЯ =====
|
||||
echo ""
|
||||
echo "Шаг 4/4: Финальная проверка..."
|
||||
|
||||
# Проверяем размер
|
||||
SIZE=$(ls -lh ${BUILD_DIR}/${APP_NAME} | awk '{print $5}')
|
||||
echo " Размер бинарного файла: ${SIZE}"
|
||||
|
||||
# Проверяем наличие embed файлов
|
||||
echo " Проверка встроенных файлов..."
|
||||
if [ -d "cmd/server/web/dist" ]; then
|
||||
echo " TypeScript файлы (dist) включены в сборку"
|
||||
fi
|
||||
|
||||
if [ -d "cmd/server/web/css" ]; then
|
||||
echo " CSS файлы включены в сборку"
|
||||
fi
|
||||
|
||||
if [ -f "cmd/server/web/dashboard.html" ]; then
|
||||
echo " HTML файлы включены в сборку"
|
||||
fi
|
||||
|
||||
echo "СБОРКА ЗАВЕРШЕНА!"
|
||||
|
||||
@@ -1,27 +1,43 @@
|
||||
// cmd/server/main.go
|
||||
// gpio-monitor-server — HTTP-сервер мониторинга GPIO-шины Raspberry Pi.
|
||||
// Читает поток байт из FIFO pipe, хранит их в кольцевом буфере и бинарных
|
||||
// почасовых логах, отдаёт JSON API и встроенный веб-дашборд на :8080.
|
||||
// Конфигурация — только флагами командной строки (см. main или docs/operations.md).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gpio-monitor/internal/adapter"
|
||||
"gpio-monitor/internal/audio"
|
||||
"gpio-monitor/internal/logger"
|
||||
"gpio-monitor/internal/pipe"
|
||||
)
|
||||
|
||||
// ===== EMBED WEB =====
|
||||
//go:embed web/*
|
||||
//go:embed web/dist web/css web/fonts web/*.html web/*.png
|
||||
var webFiles embed.FS
|
||||
|
||||
// API — состояние HTTP-хендлеров: кольцевой буфер, время старта сервера
|
||||
// и монитор звука. Спецификация ответов — docs/api.md.
|
||||
type API struct {
|
||||
buf *adapter.RingBuffer
|
||||
startTime time.Time
|
||||
buf *adapter.RingBuffer
|
||||
startTime time.Time
|
||||
audioMonitor *audio.Monitor
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
@@ -29,6 +45,373 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// HandleLogFiles отдаёт список бинарных лог-файлов gpio-*.bin,
|
||||
// отсортированный по времени изменения (новые первыми).
|
||||
func (a *API) HandleLogFiles(w http.ResponseWriter, r *http.Request) {
|
||||
dataDir, err := logger.GetDataLogsDir()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
type LogFileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"mod_time"`
|
||||
Time string `json:"time"`
|
||||
Date string `json:"date"`
|
||||
Hour int `json:"hour"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
var result []LogFileInfo
|
||||
for _, file := range files {
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
base := filepath.Base(file)
|
||||
parts := strings.Split(strings.TrimSuffix(base, ".bin"), "-")
|
||||
|
||||
var fileTime time.Time
|
||||
if len(parts) == 5 {
|
||||
year, _ := strconv.Atoi(parts[1])
|
||||
month, _ := strconv.Atoi(parts[2])
|
||||
day, _ := strconv.Atoi(parts[3])
|
||||
hour, _ := strconv.Atoi(parts[4])
|
||||
fileTime = time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.Local)
|
||||
} else {
|
||||
fileTime = info.ModTime()
|
||||
}
|
||||
|
||||
result = append(result, LogFileInfo{
|
||||
Name: base,
|
||||
Path: file,
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
Time: fileTime.Format("02.01.2006 15:00"),
|
||||
Date: fileTime.Format("2006-01-02"),
|
||||
Hour: fileTime.Hour(),
|
||||
IsActive: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Сортируем по времени
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ModTime.After(result[j].ModTime)
|
||||
})
|
||||
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
// HandleLogEvents отдаёт события из events_human.log с пагинацией
|
||||
// (page, page_size; страница 1 — самые новые).
|
||||
func (a *API) HandleLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
// Параметры пагинации
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
pageSize := 100
|
||||
if ps := r.URL.Query().Get("page_size"); ps != "" {
|
||||
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 {
|
||||
pageSize = parsed
|
||||
}
|
||||
}
|
||||
|
||||
eventsPath, err := logger.GetEventHumanLogPath()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(eventsPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Разбиваем на строки
|
||||
lines := strings.Split(string(content), "\n")
|
||||
|
||||
// Удаляем пустые строки
|
||||
var nonEmptyLines []string
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
nonEmptyLines = append(nonEmptyLines, line)
|
||||
}
|
||||
}
|
||||
lines = nonEmptyLines
|
||||
|
||||
total := len(lines)
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
// Корректируем номер страницы
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
start := total - page*pageSize
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
// Получаем строки для текущей страницы
|
||||
var pageLines []string
|
||||
if start < total && start >= 0 {
|
||||
pageLines = lines[start:end]
|
||||
} else {
|
||||
pageLines = []string{}
|
||||
}
|
||||
|
||||
var reversedLines []string
|
||||
for i := len(pageLines) - 1; i >= 0; i-- {
|
||||
reversedLines = append(reversedLines, pageLines[i])
|
||||
}
|
||||
|
||||
// Парсим строки для JSON
|
||||
type Event struct {
|
||||
Time string `json:"time"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
var events []Event
|
||||
for _, line := range reversedLines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "] EVENT: ", 2)
|
||||
if len(parts) == 2 {
|
||||
events = append(events, Event{
|
||||
Time: strings.TrimPrefix(parts[0], "["),
|
||||
Event: parts[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"events": events,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": totalPages,
|
||||
"has_previous": page > 1,
|
||||
"has_next": page < totalPages,
|
||||
"returned": len(events),
|
||||
"start_index": start + 1,
|
||||
"end_index": end,
|
||||
"order": "newest_first",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// HandleLogData отдаёт сэмплы одного .bin-файла (параметр file) с пагинацией
|
||||
// (page, page_size; страница 1 — самые новые). Формат записи — 9 байт,
|
||||
// см. docs/data-formats.md. Файл читается в память целиком.
|
||||
func (a *API) HandleLogData(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.URL.Query().Get("file")
|
||||
if filename == "" {
|
||||
http.Error(w, "отсутствует параметр file", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Параметры пагинации
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
pageSize := 100
|
||||
if ps := r.URL.Query().Get("page_size"); ps != "" {
|
||||
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 {
|
||||
pageSize = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Защита от path traversal
|
||||
filename = filepath.Base(filename)
|
||||
dataDir, err := logger.GetDataLogsDir()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dataDir, filename)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
http.Error(w, "файл не найден", 404)
|
||||
return
|
||||
}
|
||||
|
||||
// Читаем бинарный файл
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
type Sample struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Time string `json:"time"`
|
||||
Value byte `json:"value"`
|
||||
Amplitude byte `json:"amplitude"` // Старшие 2 бита (0-3)
|
||||
Signal byte `json:"signal"` // Младшие 6 бит (0-63)
|
||||
StrengthName string `json:"strength_name"`
|
||||
}
|
||||
|
||||
// Сначала читаем все сэмплы
|
||||
var allSamples []Sample
|
||||
buf := make([]byte, 9) // 8 байт timestamp + 1 байт value
|
||||
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if n < 9 {
|
||||
continue
|
||||
}
|
||||
|
||||
ts := int64(binary.LittleEndian.Uint64(buf[0:8]))
|
||||
value := buf[8]
|
||||
|
||||
amplitude := (value >> 6) & 0x3
|
||||
signal := value & 0x3F
|
||||
|
||||
allSamples = append(allSamples, Sample{
|
||||
Timestamp: ts,
|
||||
Time: time.UnixMicro(ts).Format("02.01.2006 15:04:05"),
|
||||
Value: value,
|
||||
Amplitude: amplitude,
|
||||
Signal: signal,
|
||||
StrengthName: logger.GetStrengthName(amplitude),
|
||||
})
|
||||
}
|
||||
|
||||
total := len(allSamples)
|
||||
|
||||
if total == 0 {
|
||||
writeJSON(w, map[string]any{
|
||||
"filename": filename,
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"page_size": pageSize,
|
||||
"total_pages": 1,
|
||||
"has_previous": false,
|
||||
"has_next": false,
|
||||
"start_index": 0,
|
||||
"end_index": 0,
|
||||
"samples": []Sample{},
|
||||
"stats": map[string]any{
|
||||
"total_points": 0,
|
||||
"amplitude_over_0": 0,
|
||||
"signal_over_10": 0,
|
||||
},
|
||||
"order": "newest_first",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
// Корректируем номер страницы
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
start := total - page*pageSize
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
// Получаем сэмплы для текущей страницы
|
||||
var pageSamples []Sample
|
||||
if start < total && start >= 0 {
|
||||
pageSamples = allSamples[start:end]
|
||||
} else {
|
||||
pageSamples = []Sample{}
|
||||
}
|
||||
|
||||
// Разворачиваем, чтобы новые были сверху
|
||||
var reversedSamples []Sample
|
||||
for i := len(pageSamples) - 1; i >= 0; i-- {
|
||||
reversedSamples = append(reversedSamples, pageSamples[i])
|
||||
}
|
||||
|
||||
// Считаем статистику
|
||||
amplitudeOver0 := 0
|
||||
signalOver10 := 0
|
||||
for _, s := range allSamples {
|
||||
if s.Amplitude > 0 {
|
||||
amplitudeOver0++
|
||||
}
|
||||
if s.Signal > 10 {
|
||||
signalOver10++
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"filename": filename,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": totalPages,
|
||||
"has_previous": page > 1,
|
||||
"has_next": page < totalPages,
|
||||
"start_index": start + 1,
|
||||
"end_index": end,
|
||||
"samples": reversedSamples,
|
||||
"stats": map[string]any{
|
||||
"total_points": total,
|
||||
"amplitude_over_0": amplitudeOver0,
|
||||
"signal_over_10": signalOver10,
|
||||
},
|
||||
"order": "newest_first",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleHealth отдаёт статус сервера: связь с pipe (пороги 15 с и 3 с),
|
||||
// uptime, статистику буфера и уровень звука.
|
||||
func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
latest, ok := a.buf.GetLatest()
|
||||
idle := time.Since(a.buf.LastWriteTime())
|
||||
@@ -38,6 +421,13 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status = "Нет связи"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
soundLevel := 0
|
||||
if a.audioMonitor != nil {
|
||||
soundLevel = a.audioMonitor.GetLevel()
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"status": status,
|
||||
"uptime_sec": time.Since(a.startTime).Seconds(),
|
||||
@@ -46,9 +436,12 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
"has_data": ok,
|
||||
"latest": latest,
|
||||
"stats": a.buf.Stats(),
|
||||
"server_time": now.Format("02.01.2006 15:04"),
|
||||
"sound_level": soundLevel,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleLatest отдаёт последний байт и до 10 предыдущих (от старых к новым).
|
||||
func (a *API) HandleLatest(w http.ResponseWriter, r *http.Request) {
|
||||
latest, ok := a.buf.GetLatest()
|
||||
if !ok {
|
||||
@@ -62,8 +455,9 @@ func (a *API) HandleLatest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// HandleHistory отдаёт до 300 последних байт для графика (от старых к новым).
|
||||
func (a *API) HandleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
raw := a.buf.GetLast(100)
|
||||
raw := a.buf.GetLast(300)
|
||||
|
||||
out := make([]int, len(raw))
|
||||
for i, v := range raw {
|
||||
@@ -75,69 +469,225 @@ func (a *API) HandleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ===== PIPE READER =====
|
||||
// HandleStream отдаёт статус видеопотока камеры. Внимание: доступность
|
||||
// проверяется по захардкоженному localhost:1984 без учёта флага -camera-url
|
||||
// (известное поведение, docs/tech-debt.md).
|
||||
func (a *API) HandleStream(w http.ResponseWriter, r *http.Request) {
|
||||
// Проверяем доступность MJPEG потока в go2rtc
|
||||
resp, err := http.Get("http://localhost:1984/api/streams?src=cam_mjpeg")
|
||||
camAvailable := err == nil && resp.StatusCode == 200
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func startPipeReader(ctx context.Context, buf *adapter.RingBuffer, path string) {
|
||||
go func() {
|
||||
buffer := make([]byte, 64)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("pipe connected")
|
||||
|
||||
for {
|
||||
n, err := f.Read(buffer)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
buf.Write(buffer[i])
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("pipe disconnected, retry...")
|
||||
}
|
||||
}()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"cam": "/api/cam",
|
||||
"available": camAvailable,
|
||||
"source": fmt.Sprintf("http://%s:1984/api/stream.mjpeg?src=cam_mjpeg", strings.Split(r.Host, ":")[0]),
|
||||
})
|
||||
}
|
||||
|
||||
// handleCamProxy — НЕ ИСПОЛЬЗУЕТСЯ: роутер вызывает handleCamProxyWithURL,
|
||||
// этот вариант с захардкоженным URL остался как мёртвый код (docs/tech-debt.md).
|
||||
func handleCamProxy(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[cam] запрос от %s", r.RemoteAddr)
|
||||
|
||||
resp, err := http.Get("http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg")
|
||||
if err != nil {
|
||||
log.Printf("[cam] go2rtc недоступен: %v", err)
|
||||
http.Error(w, "камера недоступна", 503)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Прокидываем заголовки
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "поток не поддерживается", 500)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
_, err = w.Write(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("[cam] клиент отключился")
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("[cam] поток завершен: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cors разрешает кросс-доменные GET-запросы с любых источников
|
||||
// (рассчитано на доверенную локальную сеть).
|
||||
func cors(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(200)
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
pipePath := flag.String("pipe", "/tmp/gpio_pipe", "pipe path")
|
||||
// Параметры командной строки
|
||||
pipePath := flag.String("pipe", "/tmp/gpio_pipe", "путь к pipe")
|
||||
|
||||
// Параметры retention (хранения)
|
||||
retentionHours := flag.Int("retention-hours", 48, "количество часов хранения логов (0 = отключено)")
|
||||
retentionMB := flag.Int("retention-mb", 5000, "максимальный размер логов в MB (0 = отключено)")
|
||||
retentionIntervalMin := flag.Int("retention-interval", 15, "интервал проверки retention в минутах")
|
||||
|
||||
// Параметры ротации файлов
|
||||
rotationCheckIntervalMin := flag.Int("rotation-check-interval", 1, "интервал проверки ротации в минутах")
|
||||
|
||||
// Параметры буфера
|
||||
bufferSize := flag.Int("buffer-size", 10240, "размер кольцевого буфера (количество элементов)")
|
||||
|
||||
// Параметры мониторинга
|
||||
silence5minAlert := flag.Int("silence-5min", 5, "время тишины для алерта 5 минут (в минутах)")
|
||||
silence10minAlert := flag.Int("silence-10min", 10, "время тишины для алерта 10 минут (в минутах)")
|
||||
watchdogIntervalMin := flag.Int("watchdog-interval", 1, "интервал проверки watchdog в минутах")
|
||||
|
||||
// Параметры анти-спама
|
||||
alertCooldownSec := flag.Int("alert-cooldown", 10, "задержка между одинаковыми алертами в секундах")
|
||||
humanLogIntervalSec := flag.Int("human-log-interval", 5, "интервал записи human-readable логов в секундах")
|
||||
|
||||
// Параметры сервера
|
||||
serverPort := flag.String("port", ":8080", "порт для веб-сервера")
|
||||
cameraURL := flag.String("camera-url", "http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg", "URL камеры для прокси")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
buf := adapter.NewRingBuffer(1024 * 10)
|
||||
// Конвертируем в time.Duration
|
||||
retentionInterval := time.Duration(*retentionIntervalMin) * time.Minute
|
||||
rotationCheckInterval := time.Duration(*rotationCheckIntervalMin) * time.Minute
|
||||
silence5min := time.Duration(*silence5minAlert) * time.Minute
|
||||
silence10min := time.Duration(*silence10minAlert) * time.Minute
|
||||
watchdogInterval := time.Duration(*watchdogIntervalMin) * time.Minute
|
||||
alertCooldown := time.Duration(*alertCooldownSec) * time.Second
|
||||
humanLogInterval := time.Duration(*humanLogIntervalSec) * time.Second
|
||||
|
||||
// Сохраняем параметры для передачи в логгеры
|
||||
config := &logger.Config{
|
||||
RetentionHours: *retentionHours,
|
||||
RetentionMB: *retentionMB,
|
||||
RetentionIntervalMin: retentionInterval,
|
||||
Silence5Min: silence5min,
|
||||
Silence10Min: silence10min,
|
||||
WatchdogIntervalMin: watchdogInterval,
|
||||
AlertCooldownSec: alertCooldown,
|
||||
HumanLogIntervalSec: humanLogInterval,
|
||||
BufferSize: *bufferSize,
|
||||
}
|
||||
|
||||
// Логируем конфигурацию
|
||||
log.Printf("=== КОНФИГУРАЦИЯ ===")
|
||||
log.Printf("Хранение: %d часов, %d MB (проверка каждые %d мин)",
|
||||
*retentionHours, *retentionMB, *retentionIntervalMin)
|
||||
log.Printf("Ротация: проверка каждые %d мин", *rotationCheckIntervalMin)
|
||||
log.Printf("Буфер: %d элементов", *bufferSize)
|
||||
log.Printf("Алерты тишины: %d мин и %d мин (проверка каждые %d мин)",
|
||||
*silence5minAlert, *silence10minAlert, *watchdogIntervalMin)
|
||||
log.Printf("Анти-спам: %d сек, Human-лог: %d сек", *alertCooldownSec, *humanLogIntervalSec)
|
||||
log.Printf("Сервер: %s, Камера: %s", *serverPort, *cameraURL)
|
||||
log.Printf("====================")
|
||||
|
||||
// ===== ИНИЦИАЛИЗАЦИЯ ЛОГГЕРОВ =====
|
||||
|
||||
// 1. EventLogger (события системы)
|
||||
eventLogger, err := logger.NewEventLogger()
|
||||
if err != nil {
|
||||
log.Fatal("Ошибка инициализации EventLogger:", err)
|
||||
}
|
||||
defer eventLogger.Close()
|
||||
eventLogger.Event("СЕРВЕР_ЗАПУЩЕН")
|
||||
|
||||
// Выводим пути для информации
|
||||
if logsDir, err := logger.GetLogsDir(); err == nil {
|
||||
log.Printf("Логи сохраняются в: %s", logsDir)
|
||||
}
|
||||
|
||||
// 1.5 Human-readable логгер
|
||||
humanLogger, err := logger.NewHumanLogger()
|
||||
if err != nil {
|
||||
log.Fatal("Ошибка инициализации HumanLogger:", err)
|
||||
}
|
||||
defer humanLogger.Close()
|
||||
eventLogger.Event("HUMAN_ЛОГЕР_ГОТОВ")
|
||||
|
||||
// 2. Monitor (watchdog) с настройками
|
||||
monitor := logger.NewMonitor(eventLogger, config)
|
||||
monitor.SetSilenceThresholds(config.Silence5Min, config.Silence10Min)
|
||||
|
||||
// 3. DataLogger с ротацией (с интервалом проверки)
|
||||
dataLogger, err := logger.NewRotatingLogger(eventLogger, rotationCheckInterval)
|
||||
if err != nil {
|
||||
eventLogger.Event("ОШИБКА_ИНИЦИАЛИЗАЦИИ_DATA_ЛОГЕРА")
|
||||
log.Fatal("Ошибка инициализации DataLogger:", err)
|
||||
}
|
||||
defer dataLogger.Close()
|
||||
eventLogger.Event("DATA_ЛОГЕР_ГОТОВ")
|
||||
|
||||
// 3.5 Retention (автоматическая очистка) с настройками
|
||||
if *retentionHours > 0 || *retentionMB > 0 {
|
||||
retention := logger.NewRetention(*retentionHours, *retentionMB, eventLogger)
|
||||
retention.StartWithInterval(retentionInterval)
|
||||
eventLogger.Event("RETENTION_ГОТОВ")
|
||||
} else {
|
||||
log.Println("Retention отключен (часы и MB = 0)")
|
||||
}
|
||||
|
||||
// 4. RingBuffer с настройкой размера
|
||||
buf := adapter.NewRingBuffer(*bufferSize)
|
||||
|
||||
// 4.5 Монитор уровня звука с микрофона
|
||||
audioMonitor := audio.NewMonitor()
|
||||
if err := audioMonitor.Start(); err != nil {
|
||||
log.Printf("Аудио-монитор не запущен (микрофон недоступен?): %v", err)
|
||||
}
|
||||
defer audioMonitor.Stop()
|
||||
|
||||
// 5. PipeReader с настройками
|
||||
pipeReader := pipe.NewPipeReader(buf, dataLogger, humanLogger, monitor, eventLogger, config)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
pipeReader.Start(ctx, *pipePath)
|
||||
|
||||
startPipeReader(ctx, buf, *pipePath)
|
||||
|
||||
// ===== API И WEB СЕРВЕР =====
|
||||
api := &API{
|
||||
buf: buf,
|
||||
startTime: time.Now(),
|
||||
buf: buf,
|
||||
startTime: time.Now(),
|
||||
audioMonitor: audioMonitor,
|
||||
}
|
||||
|
||||
// ===== API =====
|
||||
http.Handle("/api/", http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/latest":
|
||||
cors(api.HandleLatest)(w, r)
|
||||
@@ -145,6 +695,17 @@ func main() {
|
||||
cors(api.HandleHistory)(w, r)
|
||||
case "/health":
|
||||
cors(api.HandleHealth)(w, r)
|
||||
case "/stream":
|
||||
cors(api.HandleStream)(w, r)
|
||||
case "/cam":
|
||||
// Передаем URL камеры из конфигурации
|
||||
handleCamProxyWithURL(w, r, *cameraURL)
|
||||
case "/log/files":
|
||||
cors(api.HandleLogFiles)(w, r)
|
||||
case "/log/data":
|
||||
cors(api.HandleLogData)(w, r)
|
||||
case "/log/events":
|
||||
cors(api.HandleLogEvents)(w, r)
|
||||
default:
|
||||
http.Error(w, "not found", 404)
|
||||
}
|
||||
@@ -155,9 +716,59 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
http.Handle("/", http.FileServer(http.FS(webFS)))
|
||||
|
||||
log.Println("server :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
log.Printf("Сервер запущен на %s", *serverPort)
|
||||
log.Printf("Прокси камеры доступен по адресу /api/cam (источник: %s)", *cameraURL)
|
||||
log.Fatal(http.ListenAndServe(*serverPort, nil))
|
||||
}
|
||||
|
||||
// handleCamProxyWithURL проксирует MJPEG-поток с cameraURL (флаг -camera-url),
|
||||
// ретранслируя заголовки и сбрасывая буфер после каждого чанка 32 КБ.
|
||||
func handleCamProxyWithURL(w http.ResponseWriter, r *http.Request, cameraURL string) {
|
||||
log.Printf("[cam] запрос от %s к %s", r.RemoteAddr, cameraURL)
|
||||
|
||||
resp, err := http.Get(cameraURL)
|
||||
if err != nil {
|
||||
log.Printf("[cam] камера недоступна: %v", err)
|
||||
http.Error(w, "камера недоступна", 503)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Прокидываем заголовки
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "поток не поддерживается", 500)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
_, err = w.Write(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("[cam] клиент отключился")
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("[cam] поток завершен: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
56
cmd/server/web/README.md
Normal file
56
cmd/server/web/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# GPIO Monitor Web Interface
|
||||
|
||||
Веб-интерфейс дашборда GPIO Monitor: мониторинг в реальном времени, просмотр исторических логов, видеопоток камеры. Подробная документация фронтенда — [docs/frontend.md](../../../docs/frontend.md).
|
||||
|
||||
## Требования
|
||||
|
||||
- Node.js 20.x или выше
|
||||
- npm 10.x или выше
|
||||
|
||||
Node.js нужен **только для компиляции TypeScript** — в рантайме фронтенд не имеет зависимостей и раздаётся Go-сервером из встроенных файлов.
|
||||
|
||||
## Установка Node.js на Raspberry Pi
|
||||
|
||||
```bash
|
||||
# Официальный репозиторий NodeSource
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Проверка
|
||||
node --version # v20.x
|
||||
npm --version # 10.x
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
`package.json` и `tsconfig.json` уже в репозитории — инициализировать проект не нужно, только установить зависимости:
|
||||
|
||||
```bash
|
||||
cd cmd/server/web
|
||||
npm install # однократно
|
||||
npm run build # tsc: js/*.ts → dist/*.js
|
||||
npm run watch # пересборка при изменениях
|
||||
npm run clean # удалить dist/
|
||||
```
|
||||
|
||||
Проще из корня проекта: `make build-frontend` (сам выполнит `npm install` при необходимости).
|
||||
|
||||
> **Важно:** каталог `dist/` не хранится в git, но обязателен для сборки Go-сервера (`go:embed`). Всегда собирайте фронтенд до `go build` — подробности в [docs/development.md](../../../docs/development.md#порядок-сборки).
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
web/
|
||||
├── js/ # TypeScript-исходники (10 модулей, см. docs/frontend.md)
|
||||
├── dist/ # Скомпилированный JS (генерируется tsc)
|
||||
├── css/ # Стили
|
||||
├── fonts/ # Шрифты
|
||||
├── index.html # Редирект на дашборд
|
||||
├── dashboard.html # Мониторинг в реальном времени
|
||||
├── logs.html # Просмотр исторических логов
|
||||
├── favicon.png
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Карта модулей `js/`, взаимодействие с API и цикл разработки — [docs/frontend.md](../../../docs/frontend.md). Спецификация API сервера — [docs/api.md](../../../docs/api.md).
|
||||
248
cmd/server/web/css/audio.css
Normal file
248
cmd/server/web/css/audio.css
Normal file
@@ -0,0 +1,248 @@
|
||||
/* web/css/audio.css */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== АУДИО ИНДИКАТОР ===== */
|
||||
#audio-indicator {
|
||||
font-family: var(--font-tech);
|
||||
font-size: var(--font-size-lg);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid #333;
|
||||
min-width: 60px; /* Фиксированная минимальная ширина */
|
||||
width: 60px; /* Фиксированная ширина */
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#audio-indicator.active {
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#audio-indicator.muted {
|
||||
color: #666 !important;
|
||||
border-color: #333;
|
||||
animation: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* ===== ОБЕРТКА ДЛЯ АУДИО ===== */
|
||||
.audio-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===== РЕГУЛЯТОР ГРОМКОСТИ ===== */
|
||||
.volume-control {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 20, 15, 0.95);
|
||||
border: 1px solid rgba(0, 255, 136, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
animation: volumeFadeIn 0.15s ease;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.audio-wrapper:hover .volume-control {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.volume-control:hover {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
@keyframes volumeFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-4px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== ОБЕРТКА ДЛЯ ПОЛЗУНКА ===== */
|
||||
.volume-slider-wrapper {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===== ТРЕК (фон) ===== */
|
||||
.volume-track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== ЗАЛИВКА ТРЕКА ===== */
|
||||
.volume-track-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #00ff88;
|
||||
border-radius: 2px;
|
||||
transition: width 0.05s linear;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* ===== ПОЛЗУНОК ===== */
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #00ff88;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 16px rgba(0, 255, 136, 0.5);
|
||||
transition: all 0.15s ease;
|
||||
border: 2px solid rgba(0, 255, 136, 0.3);
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 24px rgba(0, 255, 136, 0.7);
|
||||
border-color: #00ff88;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #00ff88;
|
||||
border: 2px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 16px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 24px rgba(0, 255, 136, 0.7);
|
||||
border-color: #00ff88;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */
|
||||
@media (max-width: 768px) {
|
||||
.volume-control {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: auto;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
background: rgba(10, 20, 15, 0.98);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
display: none !important;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.audio-wrapper .volume-control.visible {
|
||||
display: flex !important;
|
||||
animation: volumeFadeInMobile 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes volumeFadeInMobile {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(10px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.volume-slider-wrapper {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.volume-control {
|
||||
padding: 10px 16px;
|
||||
bottom: 70px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.volume-slider-wrapper {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.audio-wrapper:hover .volume-control {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-wrapper.touch-active .volume-control {
|
||||
display: flex !important;
|
||||
}
|
||||
158
cmd/server/web/css/base.css
Normal file
158
cmd/server/web/css/base.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* ===== БАЗОВЫЕ СТИЛИ ===== */
|
||||
:root {
|
||||
/* Основные шрифты */
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||
--font-display: 'Orbitron', 'Share Tech Mono', 'Segoe UI', sans-serif;
|
||||
--font-tech: 'Share Tech Mono', 'Courier New', monospace;
|
||||
|
||||
/* Размеры */
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-base: 15px;
|
||||
--font-size-lg: 17px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 24px;
|
||||
--font-size-3xl: 30px;
|
||||
|
||||
/* Веса */
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
background: #0a0f0a; /* Чуть светлее для лучшего контраста */
|
||||
color: #00ff88;
|
||||
padding: 12px;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.7;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ===== ЗАГОЛОВКИ ===== */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
text-shadow: 0 0 30px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
/* ===== МОНОШИРИННЫЙ ТЕКСТ ===== */
|
||||
code, pre, .mono {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
/* ===== ТЕХНИЧЕСКИЙ ТЕКСТ ===== */
|
||||
.status-text,
|
||||
.value-text,
|
||||
.signal-text,
|
||||
.tech-text {
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ===== УЛУЧШЕНИЯ ЧИТАЕМОСТИ ===== */
|
||||
.text-muted {
|
||||
opacity: 0.6;
|
||||
color: #88ffbb;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.text-semibold {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* ===== ССЫЛКИ ===== */
|
||||
a {
|
||||
color: #00ff88;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #66ffbb;
|
||||
text-shadow: 0 0 25px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
/* ===== УЛУЧШЕННЫЙ СКРОЛЛ НА МОБИЛЬНЫХ ===== */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Плавный скролл */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Улучшенный скролл на мобильных */
|
||||
.panel,
|
||||
.logs-content,
|
||||
.logs-sidebar,
|
||||
#history,
|
||||
#dataTableContainer {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* ===== ГЛОБАЛЬНЫЕ СТИЛИ СКРОЛЛБАРОВ ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 136, 0.3);
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 255, 136, 0.3) rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
/* Убираем скролл-бары на мобильных где не нужно */
|
||||
@media (max-width: 768px) {
|
||||
.panel::-webkit-scrollbar,
|
||||
.logs-content::-webkit-scrollbar,
|
||||
.logs-sidebar::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
82
cmd/server/web/css/camera.css
Normal file
82
cmd/server/web/css/camera.css
Normal file
@@ -0,0 +1,82 @@
|
||||
/* Стили камеры (cam-modal, cam-frame, crosshair) */
|
||||
.cam-modal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.cam-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
background: #111;
|
||||
color: #00ff88;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-size-base);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cam-frame-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background: black;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cam-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: black;
|
||||
}
|
||||
|
||||
/* Перекрестие (crosshair) */
|
||||
.cam-crosshair {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Вертикальная линия */
|
||||
.crosshair-v {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 4px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Горизонтальная линия */
|
||||
.crosshair-h {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 4px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Центральный квадрат */
|
||||
.crosshair-box {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px solid #00ff88;
|
||||
box-shadow:
|
||||
0 0 10px rgba(0, 255, 136, 0.3),
|
||||
inset 0 0 10px rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
476
cmd/server/web/css/cards.css
Normal file
476
cmd/server/web/css/cards.css
Normal file
@@ -0,0 +1,476 @@
|
||||
/* Стили карточек, каналов, статусов и истории */
|
||||
|
||||
/* ===== CARD ===== */
|
||||
.card {
|
||||
border: 1px solid #00ff88;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: rgba(0,255,136,0.05);
|
||||
overflow: visible;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: var(--font-size-lg);
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* ===== СТАТУС ===== */
|
||||
#status {
|
||||
font-family: var(--font-tech);
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.status-ok {
|
||||
color: #00ff88 !important;
|
||||
text-shadow: 0 0 30px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
.status-lost {
|
||||
color: #ffffff !important;
|
||||
animation: blink 1.5s infinite;
|
||||
text-shadow: 0 0 30px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.status-error {
|
||||
color: #ff4444 !important;
|
||||
text-shadow: 0 0 30px rgba(255, 68, 68, 0.3);
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
/* ===== META ===== */
|
||||
#meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
opacity: 0.8;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ===== АККОРДЕОН ===== */
|
||||
.accordion-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.accordion-header:hover {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 20px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
.accordion-body {
|
||||
max-height: 520px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
.accordion-body.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== ИСТОРИЯ ===== */
|
||||
#history {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.35;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
padding-right: 4px;
|
||||
margin: 0;
|
||||
letter-spacing: 0.02em;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
#history::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
#history::-webkit-scrollbar-track {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
#history::-webkit-scrollbar-thumb {
|
||||
background: #00ff88;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ===== LAST BYTE ===== */
|
||||
#last-byte {
|
||||
font-family: var(--font-tech);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: #00ff88;
|
||||
text-shadow: 0 0 20px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
/* ===== КАНАЛЫ ===== */
|
||||
.channels-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.channels-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
.channels-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.channels-toggle-all {
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||
color: #00ff88;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1.4;
|
||||
min-height: 30px;
|
||||
}
|
||||
.channels-toggle-all:hover {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
border-color: #00ff88;
|
||||
}
|
||||
.channels-toggle-all:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ===== СЕТКА КАНАЛОВ ===== */
|
||||
.channels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.channels-grid::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.channels-grid::-webkit-scrollbar-track {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
.channels-grid::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 136, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Карточка канала */
|
||||
.channel-item {
|
||||
background: rgba(0, 255, 136, 0.03);
|
||||
border: 1px solid rgba(0, 255, 136, 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.channel-item:hover {
|
||||
border-color: rgba(0, 255, 136, 0.25);
|
||||
background: rgba(0, 255, 136, 0.06);
|
||||
}
|
||||
.channel-item.channel-active {
|
||||
border-color: rgba(0, 255, 136, 0.4);
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
}
|
||||
.channel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.channel-header:hover {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
.channel-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: #88ffbb;
|
||||
font-family: var(--font-tech);
|
||||
}
|
||||
.channel-value {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: #ffffff;
|
||||
font-family: var(--font-tech);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.channel-toggle {
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
transition: transform 0.3s;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.channel-toggle.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.channel-body {
|
||||
padding: 0 10px 10px 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.channel-body.hidden {
|
||||
display: none;
|
||||
}
|
||||
.channel-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.channel-progress .progress {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
padding: 3px;
|
||||
background: #2f2f2f;
|
||||
border: 2px solid #555;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.6);
|
||||
}
|
||||
.channel-progress .progress .fill {
|
||||
height: 100%;
|
||||
background: #e6852d;
|
||||
border-radius: 2px;
|
||||
transition: width 0.15s ease;
|
||||
width: 0%;
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(255,255,255,0.3),
|
||||
0 0 15px rgba(255, 165, 0, 0.5);
|
||||
}
|
||||
.channel-item.channel-active .fill {
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(255,255,255,0.4),
|
||||
0 0 25px rgba(255, 165, 0, 0.7);
|
||||
}
|
||||
.channel-stats {
|
||||
font-size: var(--font-size-xs);
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ===== ОСНОВНЫЕ ПРОГРЕСС-БАРЫ (bar1, bar2, bar3) ===== */
|
||||
.progress-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.progress-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: 6px;
|
||||
color: #ffffff;
|
||||
opacity: 0.9;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 4px;
|
||||
background: #222;
|
||||
border: 2px solid #444;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
.progress-with-signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
.progress-90 {
|
||||
flex: 0 0 86%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #e6852d;
|
||||
border-radius: 2px;
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(255,255,255,0.3),
|
||||
0 0 12px rgba(230,133,45,0.5);
|
||||
transition: width 0.25s linear;
|
||||
}
|
||||
.fill.no-connection {
|
||||
background: #555555 !important;
|
||||
box-shadow: none !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== ЗВУК И ВЫСОТА ===== */
|
||||
.card:has(#bar2) .progress,
|
||||
.card:has(#bar3) .progress {
|
||||
height: 34px;
|
||||
padding: 6px;
|
||||
background: #222;
|
||||
border: 2px solid #444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.card:has(#bar2) .fill,
|
||||
.card:has(#bar3) .fill {
|
||||
background: #e6852d;
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(255,255,255,0.3),
|
||||
0 0 12px rgba(230,133,45,0.5);
|
||||
}
|
||||
|
||||
/* ===== ИНДИКАТОРЫ СИГНАЛА ===== */
|
||||
.signal-icon {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sig-bar {
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
min-width: 5px;
|
||||
border: 2px solid #e6852d;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.3s, border-color 0.3s;
|
||||
}
|
||||
.sig-bar-1 { height: 33%; }
|
||||
.sig-bar-2 { height: 66%; }
|
||||
.sig-bar-3 { height: 100%; }
|
||||
.sig-bar.dim {
|
||||
background-color: transparent;
|
||||
border-color: #1a3a1a;
|
||||
}
|
||||
.sig-bar.active-1,
|
||||
.sig-bar.active-2,
|
||||
.sig-bar.active-3 {
|
||||
background-color: #e6852d;
|
||||
border-color: #c46b1f;
|
||||
}
|
||||
.sig-bar.no-connection {
|
||||
background-color: #555555 !important;
|
||||
border-color: #555555 !important;
|
||||
opacity: 0.3 !important;
|
||||
}
|
||||
|
||||
/* ===== АНИМАЦИЯ ===== */
|
||||
.channel-item.updating .channel-value {
|
||||
animation: pulse-value 0.15s ease;
|
||||
}
|
||||
@keyframes pulse-value {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); color: #ff0; }
|
||||
}
|
||||
|
||||
/* ===== КАРТОЧКИ НА ДЕСКТОПЕ ===== */
|
||||
@media (min-width: 769px) {
|
||||
.card {
|
||||
max-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.channels-card {
|
||||
max-height: 600px;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.channels-card .channels-grid {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.history-card {
|
||||
max-height: 500px;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.history-card #history {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */
|
||||
@media (max-width: 768px) {
|
||||
.channels-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.channels-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
.channels-controls {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.channel-label {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.channel-value {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
#history {
|
||||
max-height: 300px;
|
||||
}
|
||||
.history-card #history {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.channels-grid {
|
||||
max-height: 350px;
|
||||
gap: 3px;
|
||||
}
|
||||
.channel-header {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.channel-body {
|
||||
padding: 0 8px 8px 8px;
|
||||
}
|
||||
.channel-stats {
|
||||
font-size: 9px;
|
||||
}
|
||||
#history {
|
||||
max-height: 200px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.history-card #history {
|
||||
max-height: 180px;
|
||||
}
|
||||
}
|
||||
93
cmd/server/web/css/components.css
Normal file
93
cmd/server/web/css/components.css
Normal file
@@ -0,0 +1,93 @@
|
||||
/* Компоненты (прогресс-бары, сигнальные иконки) */
|
||||
/* ===== PROGRESS BARS ===== */
|
||||
.progress-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: 6px;
|
||||
color: #ffffff;
|
||||
opacity: 0.9;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 6px;
|
||||
border: 3px solid #8a8a8a;
|
||||
background: #222;
|
||||
box-shadow:
|
||||
inset 0 0 6px rgba(255,255,255,0.15),
|
||||
0 0 4px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.progress-with-signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.progress-90 {
|
||||
flex: 0 0 86%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: #e6852d;
|
||||
box-shadow:
|
||||
inset 0 0 4px rgba(255,255,255,0.2),
|
||||
0 0 6px rgba(230,133,45,0.5);
|
||||
transition: width 0.25s linear;
|
||||
}
|
||||
|
||||
.fill.no-connection {
|
||||
background: #555555 !important;
|
||||
box-shadow: none !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== SIGNAL ICON ===== */
|
||||
.signal-icon {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sig-bar {
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
min-width: 5px;
|
||||
border: 2px solid #e6852d;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
.sig-bar-1 { height: 33%; }
|
||||
.sig-bar-2 { height: 66%; }
|
||||
.sig-bar-3 { height: 100%; }
|
||||
|
||||
.sig-bar.dim {
|
||||
background-color: transparent;
|
||||
border-color: #1a3a1a;
|
||||
}
|
||||
|
||||
.sig-bar.active-1,
|
||||
.sig-bar.active-2,
|
||||
.sig-bar.active-3 {
|
||||
background-color: #e6852d;
|
||||
border-color: #c46b1f;
|
||||
}
|
||||
|
||||
.sig-bar.no-connection {
|
||||
background-color: #555555 !important;
|
||||
border-color: #555555 !important;
|
||||
opacity: 0.3 !important;
|
||||
}
|
||||
58
cmd/server/web/css/fonts.css
Normal file
58
cmd/server/web/css/fonts.css
Normal file
@@ -0,0 +1,58 @@
|
||||
/* ===== ЛОКАЛЬНЫЕ ШРИФТЫ ===== */
|
||||
|
||||
/* JetBrains Mono */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Orbitron - используем существующий файл */
|
||||
@font-face {
|
||||
font-family: 'Orbitron';
|
||||
src: url('/fonts/orbitron-bold-webfont.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Share Tech Mono */
|
||||
@font-face {
|
||||
font-family: 'Share Tech Mono';
|
||||
src: url('/fonts/ShareTechMono-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ===== ПЕРЕМЕННЫЕ СО ШРИФТАМИ ===== */
|
||||
:root {
|
||||
--font-mono: 'JetBrains Mono', 'Share Tech Mono', 'Courier New', monospace;
|
||||
--font-display: 'Orbitron', 'Share Tech Mono', 'Arial Black', sans-serif;
|
||||
--font-tech: 'Share Tech Mono', 'JetBrains Mono', 'Courier New', monospace;
|
||||
|
||||
/* Размеры шрифтов */
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-base: 15px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 22px;
|
||||
--font-size-2xl: 28px;
|
||||
--font-size-3xl: 36px;
|
||||
|
||||
/* Веса шрифтов */
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--font-weight-extrabold: 800;
|
||||
}
|
||||
201
cmd/server/web/css/header.css
Normal file
201
cmd/server/web/css/header.css
Normal file
@@ -0,0 +1,201 @@
|
||||
/* ===== HEADER BAR ===== */
|
||||
.header-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-bar h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-2xl);
|
||||
letter-spacing: 0.1em;
|
||||
text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.header-time {
|
||||
font-family: var(--font-tech);
|
||||
font-size: var(--font-size-lg);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.header-bar .cam-btn {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* ===== CAMERA BUTTON ===== */
|
||||
.cam-btn {
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #00ff88;
|
||||
border: 1px solid #00ff88;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.cam-btn:hover {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cam-btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
/* ===== HEADER CONTROLS ===== */
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
color: #00ff88;
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-base);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-link:hover {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
.audio-indicator {
|
||||
font-size: var(--font-size-lg);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid #333;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
font-family: var(--font-tech);
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.audio-indicator.active {
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== CAMERA CLOSE BUTTON ===== */
|
||||
.cam-close-btn {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
color: #ff4466;
|
||||
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cam-close-btn:hover {
|
||||
background: rgba(255, 68, 68, 0.3);
|
||||
border-color: #ff4466;
|
||||
box-shadow: 0 0 20px rgba(255, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* ===== МОБИЛЬНАЯ АДАПТАЦИЯ ХЕДЕРА ===== */
|
||||
@media (max-width: 768px) {
|
||||
.header-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-bar h1 {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.audio-indicator {
|
||||
font-size: var(--font-size-base);
|
||||
padding: 4px 8px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.cam-btn {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-bar h1 {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.audio-indicator {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 3px 6px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.cam-btn {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
168
cmd/server/web/css/layout.css
Normal file
168
cmd/server/web/css/layout.css
Normal file
@@ -0,0 +1,168 @@
|
||||
/* ===== SPLIT LAYOUT ===== */
|
||||
.layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 100px);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-right: 4px;
|
||||
overscroll-behavior: auto;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 300px;
|
||||
padding: 10px 10%; /* ← X% от ширины экрана */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
overscroll-behavior: auto;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 0 0 40%;
|
||||
min-width: 0;
|
||||
background: black;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.right.hidden {
|
||||
flex: 0 0 0 !important;
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
width: 8px;
|
||||
min-width: 8px;
|
||||
cursor: col-resize;
|
||||
background: #00ff88;
|
||||
opacity: 0.3;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.resizer:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.resizer.hidden {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
height: auto !important;
|
||||
min-height: 100vh;
|
||||
gap: 8px;
|
||||
overflow: visible !important;
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.panel {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
-webkit-overflow-scrolling: auto;
|
||||
padding-right: 0;
|
||||
}
|
||||
.left {
|
||||
flex: 1 1 auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 10px 16px !important;
|
||||
width: 100% !important;
|
||||
overflow-y: visible !important;
|
||||
height: auto !important;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 0 0 auto !important;
|
||||
min-width: 100% !important;
|
||||
width: 100% !important;
|
||||
height: 40vh;
|
||||
min-height: 300px;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.right.hidden {
|
||||
display: none !important;
|
||||
flex: 0 0 0 !important;
|
||||
height: 0 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.resizer {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
gap: 6px;
|
||||
}
|
||||
.right {
|
||||
height: 35vh;
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== АЛЬБОМНАЯ ОРИЕНТАЦИЯ ===== */
|
||||
@media (max-width: 768px) and (orientation: landscape) {
|
||||
.layout {
|
||||
flex-direction: row;
|
||||
height: calc(100vh - 80px) !important;
|
||||
min-height: auto;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.left {
|
||||
flex: 1 1 60% !important;
|
||||
min-width: 0 !important;
|
||||
padding-right: 8px;
|
||||
overflow-y: auto !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.right {
|
||||
flex: 0 0 40% !important;
|
||||
min-width: 0 !important;
|
||||
height: 100% !important;
|
||||
min-height: 0;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
649
cmd/server/web/css/logs.css
Normal file
649
cmd/server/web/css/logs.css
Normal file
@@ -0,0 +1,649 @@
|
||||
/* ===== СТИЛИ СТРАНИЦЫ ЖУРНАЛА ===== */
|
||||
|
||||
/* Общие улучшения читаемости */
|
||||
.logs-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 120px);
|
||||
overflow: visible; /* Разрешаем выход за границы */
|
||||
}
|
||||
|
||||
.logs-sidebar {
|
||||
flex: 0 0 280px;
|
||||
border-right: 1px solid #00ff88;
|
||||
padding-right: 16px;
|
||||
padding-left: 4px;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.logs-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ===== ФАЙЛЫ В БОКОВОЙ ПАНЕЛИ ===== */
|
||||
.log-file-item {
|
||||
padding: 10px 14px;
|
||||
margin: 4px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font-mono);
|
||||
background: rgba(0, 255, 136, 0.03);
|
||||
transform: scale(1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.log-file-item:hover {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
border-color: #00ff88;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.03);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.log-file-item.active {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.05);
|
||||
transform: scale(1);
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.log-file-item .file-name {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.log-file-item .file-info {
|
||||
font-size: var(--font-size-sm);
|
||||
opacity: 0.7;
|
||||
color: #88ffbb;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ===== СТАТИСТИКА ===== */
|
||||
.log-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.log-stat-card {
|
||||
padding: 14px 12px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.log-stat-card:hover {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 25px rgba(0, 255, 136, 0.05);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.log-stat-card .value {
|
||||
font-family: var(--font-tech);
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: #00ff88;
|
||||
text-shadow: 0 0 20px rgba(0, 255, 136, 0.2);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-stat-card .label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
opacity: 0.7;
|
||||
color: #88ffbb;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ===== ТАБЛИЦА ДАННЫХ ===== */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
font-size: var(--font-size-base);
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 2px solid #00ff88;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #050805;
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.08em;
|
||||
font-size: var(--font-size-sm);
|
||||
color: #00ff88;
|
||||
text-shadow: 0 0 20px rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.08);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background: rgba(0, 255, 136, 0.06);
|
||||
}
|
||||
|
||||
/* ===== УЛУЧШЕННЫЕ ЦВЕТА ДЛЯ АМПЛИТУДЫ ===== */
|
||||
.strength-0 {
|
||||
color: #666666;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.strength-1 {
|
||||
color: #66ff88;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-shadow: 0 0 15px rgba(102, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.strength-2 {
|
||||
color: #ffbb44;
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-shadow: 0 0 20px rgba(255, 187, 68, 0.25);
|
||||
}
|
||||
|
||||
.strength-3 {
|
||||
color: #ff3355;
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-shadow: 0 0 20px rgba(255, 51, 85, 0.4), 0 0 40px rgba(255, 51, 85, 0.2);
|
||||
background: rgba(255, 51, 85, 0.08);
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ===== СПИСОК СОБЫТИЙ ===== */
|
||||
.events-list {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.events-list > div {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.04);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.events-list > div:hover {
|
||||
background: rgba(0, 255, 136, 0.04);
|
||||
}
|
||||
|
||||
.events-list .event-time {
|
||||
opacity: 0.6;
|
||||
font-family: var(--font-tech);
|
||||
color: #88ffbb;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.events-list .event-message {
|
||||
color: #00ff88;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
/* ===== ТАБЫ ===== */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 20px;
|
||||
background: transparent;
|
||||
color: #88ffbb;
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.03em;
|
||||
transform: scale(1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: #00ff88;
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 0 25px rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
border-color: #00ff88;
|
||||
color: #00ff88;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.05);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== ПАГИНАЦИЯ ===== */
|
||||
.pagination-container {
|
||||
margin: 16px 0;
|
||||
padding: 14px 8px;
|
||||
border-top: 1px solid rgba(0, 255, 136, 0.15);
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.15);
|
||||
background: rgba(0, 255, 136, 0.02);
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
color: #00ff88;
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
transition: all 0.2s ease;
|
||||
min-width: 44px;
|
||||
transform: scale(1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
border-color: #00ff88;
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 0 25px rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
padding: 0 16px;
|
||||
color: #88ffbb;
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-mono);
|
||||
min-width: 220px;
|
||||
text-align: center;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.pagination-goto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.pagination-input {
|
||||
width: 60px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #00ff88;
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-input:focus {
|
||||
outline: none;
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
|
||||
.pagination-goto-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: var(--font-size-sm);
|
||||
background: rgba(0, 255, 136, 0.12);
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #88ffbb;
|
||||
opacity: 0.8;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
/* ===== СКРОЛЛБАРЫ ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 136, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
/* ===== АДАПТИВНОСТЬ ===== */
|
||||
@media (max-width: 768px) {
|
||||
.logs-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logs-sidebar {
|
||||
flex: 0 0 auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.2);
|
||||
padding-right: 0;
|
||||
padding-bottom: 12px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logs-sidebar h3 {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.logs-content {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.log-file-item {
|
||||
padding: 8px 12px;
|
||||
margin: 3px 0;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-file-item .file-name {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.log-file-item .file-info {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.log-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-stat-card {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.log-stat-card .value {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.log-stat-card .label {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 4px 6px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: var(--font-size-sm);
|
||||
min-height: 40px;
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
padding: 10px 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
min-height: 40px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: var(--font-size-xs);
|
||||
min-width: 80px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.pagination-goto {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.pagination-input {
|
||||
width: 44px;
|
||||
min-height: 36px;
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.pagination-goto-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.log-stats {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.log-stat-card {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.log-stat-card .value {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 3px 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 4px 10px;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
min-height: 34px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.pagination-input {
|
||||
width: 38px;
|
||||
min-height: 32px;
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 3px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== НОМЕРА ЗАПИСЕЙ ===== */
|
||||
.event-num {
|
||||
opacity: 0.3;
|
||||
font-size: var(--font-size-xs);
|
||||
margin-right: 8px;
|
||||
color: #666;
|
||||
min-width: 30px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.data-table td:first-child {
|
||||
opacity: 0.3;
|
||||
font-size: var(--font-size-xs);
|
||||
color: #666;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
/* ===== ПУСТОЕ СОСТОЯНИЕ ===== */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #88ffbb;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.5;
|
||||
color: #88ffbb;
|
||||
}
|
||||
|
||||
/* ===== ЗАГРУЗКА ===== */
|
||||
.loading-indicator {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #88ffbb;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-lg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.loading-indicator::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 12px;
|
||||
border: 2px solid #00ff88;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== БОКОВАЯ ПАНЕЛЬ ===== */
|
||||
.logs-sidebar {
|
||||
flex: 0 0 280px;
|
||||
border-right: 1px solid #00ff88;
|
||||
padding-right: 16px;
|
||||
padding-left: 4px;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logs-sidebar.hidden {
|
||||
flex: 0 0 0 !important;
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 120px);
|
||||
overflow: visible;
|
||||
transition: gap 0.3s ease;
|
||||
}
|
||||
196
cmd/server/web/css/responsive.css
Normal file
196
cmd/server/web/css/responsive.css
Normal file
@@ -0,0 +1,196 @@
|
||||
/* ===== РЕСПОНСИВНЫЙ ДИЗАЙН ===== */
|
||||
|
||||
/* ===== ПЛАНШЕТЫ ===== */
|
||||
@media (max-width: 1024px) {
|
||||
body { padding: 10px; }
|
||||
.header-bar { gap: 10px; grid-template-columns: 1fr auto; }
|
||||
.header-bar h1 { font-size: var(--font-size-xl); }
|
||||
.header-time { font-size: var(--font-size-sm); padding: 4px 10px; }
|
||||
.cam-btn { font-size: var(--font-size-sm); padding: 6px 12px; }
|
||||
.card { padding: 12px 14px; }
|
||||
.card h3 { font-size: var(--font-size-base); }
|
||||
#status { font-size: var(--font-size-2xl); }
|
||||
.progress { height: 28px; padding: 4px; }
|
||||
.progress-label { font-size: var(--font-size-sm); }
|
||||
.signal-icon { width: 28px; height: 28px; }
|
||||
}
|
||||
|
||||
/* ===== МОБИЛЬНЫЕ ===== */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 8px;
|
||||
font-size: var(--font-size-base);
|
||||
overflow-y: auto;
|
||||
min-height: 100vh;
|
||||
height: auto;
|
||||
}
|
||||
button, .cam-btn, .tab-btn, .pagination-btn, .log-file-item {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-bar h1 {
|
||||
font-size: var(--font-size-lg);
|
||||
text-align: center;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.header-bar > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.header-time {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 4px 10px;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
#audio-indicator {
|
||||
font-size: var(--font-size-base);
|
||||
padding: 4px 10px;
|
||||
min-width: 40px;
|
||||
}
|
||||
.cam-btn {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 8px 14px;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
.header-bar a[href="/logs.html"] {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.wide { grid-column: span 1; }
|
||||
.card { padding: 12px; border-radius: 8px; overflow: visible !important; height: auto !important; }
|
||||
.card h3 { font-size: var(--font-size-base); margin-bottom: 8px; }
|
||||
#status { font-size: var(--font-size-2xl); }
|
||||
#meta { font-size: var(--font-size-sm); }
|
||||
.progress-row { margin-bottom: 12px; }
|
||||
.progress-label { font-size: var(--font-size-sm); margin-bottom: 4px; }
|
||||
.progress { height: 30px; padding: 4px; border-width: 2px; }
|
||||
.progress-with-signal { flex-wrap: nowrap; }
|
||||
.progress-90 { flex: 1; margin-right: 8px; }
|
||||
.signal-icon { width: 30px; height: 30px; flex-shrink: 0; }
|
||||
.sig-bar { min-width: 4px; border-width: 1.5px; }
|
||||
.accordion-header { padding: 8px 0; font-size: var(--font-size-base); }
|
||||
.accordion-body { max-height: 400px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||||
.cam-modal { height: 100%; min-height: 300px; }
|
||||
.cam-header { padding: 6px 10px; font-size: var(--font-size-sm); }
|
||||
.cam-frame { object-fit: cover; }
|
||||
.crosshair-box { width: 80px; height: 80px; border-width: 1.5px; }
|
||||
.hotkey-hint { font-size: var(--font-size-xs); padding: 4px 0; flex-shrink: 0; }
|
||||
.hotkey-hint kbd { padding: 2px 6px; font-size: var(--font-size-xs); }
|
||||
.channels-grid { overscroll-behavior: auto !important; max-height: none !important; overflow-y: visible !important; }
|
||||
}
|
||||
|
||||
/* ===== МАЛЕНЬКИЕ ТЕЛЕФОНЫ ===== */
|
||||
@media (max-width: 480px) {
|
||||
body { padding: 6px; }
|
||||
.header-bar h1 { font-size: var(--font-size-base); }
|
||||
.header-bar > div { flex-wrap: wrap; }
|
||||
.header-time { font-size: var(--font-size-xs); padding: 4px 8px; min-width: 60px; }
|
||||
#audio-indicator { font-size: var(--font-size-sm); padding: 4px 8px; min-width: 35px; }
|
||||
.cam-btn { font-size: var(--font-size-xs); padding: 6px 10px; min-width: 60px; }
|
||||
.card { padding: 10px; border-radius: 6px; }
|
||||
.card h3 { font-size: var(--font-size-sm); }
|
||||
#status { font-size: var(--font-size-xl); }
|
||||
#meta { font-size: var(--font-size-xs); }
|
||||
.progress { height: 24px; padding: 3px; border-width: 2px; }
|
||||
.progress-label { font-size: var(--font-size-xs); }
|
||||
.signal-icon { width: 24px; height: 24px; }
|
||||
.sig-bar { min-width: 3px; border-width: 1px; }
|
||||
.sig-bar-1 { height: 30%; }
|
||||
.sig-bar-2 { height: 60%; }
|
||||
.sig-bar-3 { height: 90%; }
|
||||
.right { height: 35vh; min-height: 200px; }
|
||||
.cam-header { padding: 4px 8px; font-size: var(--font-size-xs); }
|
||||
.crosshair-box { width: 60px; height: 60px; }
|
||||
#history { font-size: var(--font-size-xs); max-height: 200px; }
|
||||
}
|
||||
|
||||
/* ===== ТАЧ-ИНТЕРФЕЙС ===== */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
button, .cam-btn, .tab-btn, .pagination-btn, .log-file-item,
|
||||
.accordion-header, .pagination-goto-btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.cam-btn:hover, .tab-btn:hover, .pagination-btn:hover, .log-file-item:hover {
|
||||
transform: none;
|
||||
}
|
||||
.cam-btn:active, .tab-btn:active, .pagination-btn:active, .log-file-item:active {
|
||||
transform: scale(0.95);
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
.panel, .logs-content, .logs-sidebar, #history, #dataTableContainer, .accordion-body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.pagination-input { min-height: 44px; font-size: var(--font-size-base); padding: 8px 12px; }
|
||||
input[type="checkbox"], input[type="radio"] { width: 20px; height: 20px; }
|
||||
.header-link, .cam-btn, .cam-close-btn { min-height: 44px !important; min-width: 44px !important; padding: 8px 14px !important; }
|
||||
.header-link:active, .cam-btn:active { transform: scale(0.95); }
|
||||
}
|
||||
|
||||
/* ===== ТЁМНАЯ ТЕМА ===== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #0a0f0a; }
|
||||
}
|
||||
|
||||
/* ===== ПЕЧАТЬ ===== */
|
||||
@media print {
|
||||
.header-bar, .cam-btn, .resizer, .right, .pagination-container, .hotkey-hint {
|
||||
display: none !important;
|
||||
}
|
||||
.layout { display: block !important; height: auto !important; }
|
||||
.panel { height: auto !important; overflow: visible !important; }
|
||||
.card { break-inside: avoid; border: 1px solid #00ff88; }
|
||||
}
|
||||
|
||||
/* ===== СКРОЛЛБАРЫ ===== */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: rgba(0, 255, 136, 0.05); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(0, 255, 136, 0.3); border-radius: 4px; transition: background 0.3s ease; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(0, 255, 136, 0.5); }
|
||||
* { scrollbar-width: thin; scrollbar-color: rgba(0, 255, 136, 0.3) rgba(0, 255, 136, 0.05); }
|
||||
|
||||
.panel::-webkit-scrollbar, .logs-sidebar::-webkit-scrollbar,
|
||||
.logs-content::-webkit-scrollbar, #history::-webkit-scrollbar,
|
||||
.accordion-body::-webkit-scrollbar, .channels-grid::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.panel::-webkit-scrollbar-track, .logs-sidebar::-webkit-scrollbar-track,
|
||||
.logs-content::-webkit-scrollbar-track, #history::-webkit-scrollbar-track,
|
||||
.accordion-body::-webkit-scrollbar-track, .channels-grid::-webkit-scrollbar-track {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.panel::-webkit-scrollbar-thumb, .logs-sidebar::-webkit-scrollbar-thumb,
|
||||
.logs-content::-webkit-scrollbar-thumb, #history::-webkit-scrollbar-thumb,
|
||||
.accordion-body::-webkit-scrollbar-thumb, .channels-grid::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 136, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.panel::-webkit-scrollbar-thumb:hover, .logs-sidebar::-webkit-scrollbar-thumb:hover,
|
||||
.logs-content::-webkit-scrollbar-thumb:hover, #history::-webkit-scrollbar-thumb:hover,
|
||||
.accordion-body::-webkit-scrollbar-thumb:hover, .channels-grid::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
background: #050805;
|
||||
color: #00ff88;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
margin: 0 0 15px;
|
||||
}
|
||||
|
||||
/* ===== GRID ===== */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== CARD ===== */
|
||||
.card {
|
||||
border: 1px solid #00ff88;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: rgba(0,255,136,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ===== STATUS ===== */
|
||||
#status {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* СТАТУСЫ - ЦВЕТА */
|
||||
.status-ok {
|
||||
color: #00ff88 !important;
|
||||
}
|
||||
|
||||
.status-lost {
|
||||
color: #ffffff !important;
|
||||
animation: blink 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #ff4444 !important;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
/* ===== META ===== */
|
||||
#meta {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ===== PROGRESS BARS ===== */
|
||||
.progress-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
color: #ffffff;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 6px;
|
||||
border: 3px solid #8a8a8a;
|
||||
background: #222;
|
||||
box-shadow:
|
||||
inset 0 0 6px rgba(255,255,255,0.15),
|
||||
0 0 4px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.progress-with-signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.progress-90 {
|
||||
flex: 0 0 86%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* ЗАЛИВКА ПРОГРЕСС-БАРА */
|
||||
.fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #e6852d;
|
||||
box-shadow:
|
||||
inset 0 0 4px rgba(255,255,255,0.2),
|
||||
0 0 6px rgba(230,133,45,0.5);
|
||||
transition: width 0.25s linear;
|
||||
}
|
||||
|
||||
/* Заливка при потере связи */
|
||||
.fill.no-connection {
|
||||
background: #555555 !important;
|
||||
box-shadow: none !important;
|
||||
width: 0% !important;
|
||||
}
|
||||
|
||||
/* ===== SIGNAL ICON ===== */
|
||||
.signal-icon {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sig-bar {
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
min-width: 5px;
|
||||
border: 1.5px solid #e6852d;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
.sig-bar-1 { height: 33%; }
|
||||
.sig-bar-2 { height: 66%; }
|
||||
.sig-bar-3 { height: 100%; }
|
||||
|
||||
/* Неактивные сигнальные бары */
|
||||
.sig-bar.dim {
|
||||
background-color: transparent;
|
||||
border-color: #1a3a1a;
|
||||
}
|
||||
|
||||
/* Активные сигнальные бары */
|
||||
.sig-bar.active-1,
|
||||
.sig-bar.active-2,
|
||||
.sig-bar.active-3 {
|
||||
background-color: #e6852d;
|
||||
border-color: #c46b1f;
|
||||
}
|
||||
|
||||
/* Нет связи - сигнальные бары */
|
||||
.sig-bar.no-connection {
|
||||
background-color: #555555 !important;
|
||||
border-color: #555555 !important;
|
||||
opacity: 0.3 !important;
|
||||
}
|
||||
|
||||
/* ===== LAST BYTE ===== */
|
||||
#last-byte {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
/* ===== CHART ===== */
|
||||
#signalChart {
|
||||
width: 100%;
|
||||
background: #050805;
|
||||
border: 1px solid #e6852d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===== HISTORY ===== */
|
||||
#history {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
padding-right: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== ACCORDION ===== */
|
||||
.accordion-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
max-height: 520px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.accordion-body.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== MOBILE ===== */
|
||||
@media (max-width: 500px) {
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
#status {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 30px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#history {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
max-height: 55vh;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,138 @@
|
||||
<!-- web/dashboard.html -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>GPIO Панель</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#050805">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>G-Port Панель</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/css/fonts.css">
|
||||
<link rel="stylesheet" href="/css/base.css">
|
||||
<link rel="stylesheet" href="/css/header.css">
|
||||
<link rel="stylesheet" href="/css/layout.css">
|
||||
<link rel="stylesheet" href="/css/cards.css">
|
||||
<link rel="stylesheet" href="/css/components.css">
|
||||
<link rel="stylesheet" href="/css/camera.css">
|
||||
<link rel="stylesheet" href="/css/audio.css">
|
||||
<link rel="stylesheet" href="/css/responsive.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<div class="card">
|
||||
<h3>СОСТОЯНИЕ</h3>
|
||||
<div id="status">...</div>
|
||||
<div id="meta"></div>
|
||||
<div class="header-bar">
|
||||
<h1>G-Port Warden</h1>
|
||||
<div class="header-controls">
|
||||
<a href="/logs.html" class="header-link">Журнал</a>
|
||||
<!-- Аудио индикатор с регулятором -->
|
||||
<div class="audio-wrapper" id="audioWrapper">
|
||||
<div id="audio-indicator" class="audio-indicator">🔇</div>
|
||||
<!-- Регулятор громкости -->
|
||||
<div class="volume-control" id="volumeControl">
|
||||
<div class="volume-slider-wrapper">
|
||||
<input type="range" id="volumeSlider" class="volume-slider"
|
||||
min="0" max="100" value="0" step="1">
|
||||
<div class="volume-track">
|
||||
<div class="volume-track-fill" id="volumeTrackFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="server-time" class="header-time">--:--:--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<h3>УРОВНИ КАНАЛОВ</h3>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Канал 1 — <span id="last-byte">0</span></div>
|
||||
<div class="progress-with-signal">
|
||||
<div class="progress progress-90">
|
||||
<div class="fill" id="bar1"></div>
|
||||
<!-- Остальной код без изменений -->
|
||||
<div class="layout">
|
||||
<!-- LEFT -->
|
||||
<div class="panel left" id="leftPanel">
|
||||
<div class="grid">
|
||||
<!-- Карточка состояния -->
|
||||
<div class="card wide" style="flex-shrink: 0;">
|
||||
<h3>СОСТОЯНИЕ</h3>
|
||||
<div id="status">...</div>
|
||||
<div id="meta"></div>
|
||||
</div>
|
||||
<div class="signal-icon" id="signal-icon" aria-label="Уровень сигнала" role="img">
|
||||
<div class="sig-bar sig-bar-1"></div>
|
||||
<div class="sig-bar sig-bar-2"></div>
|
||||
<div class="sig-bar sig-bar-3"></div>
|
||||
|
||||
<!-- Карточка каналов (8 штук) -->
|
||||
<div class="card wide channels-card">
|
||||
<div class="channels-header">
|
||||
<h3>КАНАЛЫ СИГНАЛОВ</h3>
|
||||
<div class="channels-controls">
|
||||
<button class="channels-toggle-all" id="expandAllBtn">▼</button>
|
||||
<button class="channels-toggle-all" id="collapseAllBtn">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channels-grid" id="channelsContainer">
|
||||
<!-- Каналы генерируются JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Карточка со звуком -->
|
||||
<div class="card wide">
|
||||
<h3>🔊 ЗВУК</h3>
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Уровень звука</div>
|
||||
<div class="progress">
|
||||
<div class="fill" id="bar2" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-family:var(--font-tech);font-size:var(--font-size-sm);color:#666;margin-top:4px;">
|
||||
<span id="sound-level">0</span> / 100
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Карточка с высотой -->
|
||||
<div class="card wide">
|
||||
<h3>📐 ВЫСОТА</h3>
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Уровень высоты</div>
|
||||
<div class="progress">
|
||||
<div class="fill" id="bar3" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-family:var(--font-tech);font-size:var(--font-size-sm);color:#666;margin-top:4px;">
|
||||
<span id="height-level">0</span> / 100
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Карточка истории -->
|
||||
<div class="card wide history-card">
|
||||
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
|
||||
<div class="accordion-body" id="history-body">
|
||||
<div id="history"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Звук</div>
|
||||
<div class="progress">
|
||||
<div class="fill" id="bar2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- DRAG HANDLE -->
|
||||
<div class="resizer" id="dragBar"></div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Высота</div>
|
||||
<div class="progress">
|
||||
<div class="fill" id="bar3"></div>
|
||||
<!-- RIGHT: камера -->
|
||||
<div class="panel right hidden" id="rightPanel">
|
||||
<div class="cam-modal">
|
||||
<div class="cam-header">
|
||||
<span>Видео с камеры</span>
|
||||
<button id="camCloseBtn" class="cam-close-btn" aria-label="Закрыть камеру">✕</button>
|
||||
</div>
|
||||
<div class="cam-frame-wrapper">
|
||||
<img id="camImage" src="" alt="Camera" class="cam-frame">
|
||||
<div class="cam-crosshair" id="crosshair">
|
||||
<div class="crosshair-v"></div>
|
||||
<div class="crosshair-h"></div>
|
||||
<div class="crosshair-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
|
||||
<div class="accordion-body" id="history-body">
|
||||
<div id="history"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card wide">
|
||||
<h3>ГРАФИК СИГНАЛА</h3>
|
||||
<canvas id="signalChart" height="120"></canvas>
|
||||
<div class="hotkey-hint">
|
||||
(ENG lang key) <kbd>C</kbd> — камера | <kbd>M</kbd> — звук | <kbd>Esc</kbd> — сброс
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
|
||||
<script type="module" src="/dist/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
BIN
cmd/server/web/fonts/JetBrainsMono-2.304.zip
Normal file
BIN
cmd/server/web/fonts/JetBrainsMono-2.304.zip
Normal file
Binary file not shown.
BIN
cmd/server/web/fonts/JetBrainsMono-Bold.woff2
Normal file
BIN
cmd/server/web/fonts/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
cmd/server/web/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
cmd/server/web/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
cmd/server/web/fonts/ShareTechMono-Regular.ttf
Normal file
BIN
cmd/server/web/fonts/ShareTechMono-Regular.ttf
Normal file
Binary file not shown.
BIN
cmd/server/web/fonts/master.zip
Normal file
BIN
cmd/server/web/fonts/master.zip
Normal file
Binary file not shown.
BIN
cmd/server/web/fonts/orbitron-bold-webfont.woff
Normal file
BIN
cmd/server/web/fonts/orbitron-bold-webfont.woff
Normal file
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
// accordion.js
|
||||
// accordion.ts
|
||||
import { DOM } from "./ui.js";
|
||||
|
||||
export function initAccordion() {
|
||||
export function initAccordion(): void {
|
||||
if (!DOM.historyHeader || !DOM.historyBody) return;
|
||||
|
||||
let open = false;
|
||||
@@ -12,9 +12,12 @@ export function initAccordion() {
|
||||
DOM.historyHeader.addEventListener("click", () => {
|
||||
open = !open;
|
||||
|
||||
DOM.historyHeader.textContent =
|
||||
(open ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ";
|
||||
if (DOM.historyHeader) {
|
||||
DOM.historyHeader.textContent = (open ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ";
|
||||
}
|
||||
|
||||
DOM.historyBody.classList.toggle("collapsed", !open);
|
||||
if (DOM.historyBody) {
|
||||
DOM.historyBody.classList.toggle("collapsed", !open);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
// app.js (оркестратор)
|
||||
import { fetchHealth, fetchHistory } from "./data.js";
|
||||
import { state, smooth, addSignal } from "./state.js";
|
||||
import {
|
||||
initDOM,
|
||||
setStatus,
|
||||
setMeta,
|
||||
setBars,
|
||||
setSignal,
|
||||
DOM
|
||||
} from "./ui.js";
|
||||
|
||||
import { initAccordion } from "./accordion.js";
|
||||
import { drawChart } from "./chart.js";
|
||||
|
||||
let lastChart = 0;
|
||||
let connectionLost = false;
|
||||
let consecutiveErrors = 0; // счётчик ошибок подряд
|
||||
const MAX_ERRORS = 3; // после скольки ошибок замедляем опрос
|
||||
let pollTimer = null; // храним ID таймера для возможности сброса
|
||||
|
||||
// ================= INIT =================
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
initDOM();
|
||||
initAccordion();
|
||||
});
|
||||
|
||||
// ================= UPDATE =================
|
||||
async function update() {
|
||||
try {
|
||||
const [h, hist] = await Promise.all([
|
||||
fetchHealth(),
|
||||
fetchHistory()
|
||||
]);
|
||||
|
||||
consecutiveErrors = 0;
|
||||
|
||||
if (connectionLost) {
|
||||
console.log("[app] Соединение восстановлено");
|
||||
}
|
||||
|
||||
// ===== STATUS =====
|
||||
const isOnline = h.pipe_alive && h.has_data;
|
||||
|
||||
if (isOnline) {
|
||||
setStatus("На связи");
|
||||
connectionLost = false;
|
||||
} else {
|
||||
setStatus("Нет связи", true);
|
||||
connectionLost = true;
|
||||
}
|
||||
|
||||
const arr = Array.isArray(hist.bytes) ? hist.bytes : [];
|
||||
const raw = arr[arr.length - 1] ?? 0;
|
||||
|
||||
// ===== DECODE =====
|
||||
const value = raw & 0x3F;
|
||||
const level = (raw >> 6) & 0x3;
|
||||
const percent = (value / 63) * 100;
|
||||
|
||||
// ===== STATE =====
|
||||
addSignal(value);
|
||||
|
||||
// ===== UI =====
|
||||
if (isOnline) {
|
||||
setBars(smooth(percent));
|
||||
setSignal(level);
|
||||
} else {
|
||||
setBars(0, true);
|
||||
setSignal(0, true);
|
||||
}
|
||||
|
||||
if (DOM.lastByte) {
|
||||
DOM.lastByte.textContent = isOnline ? value : "--";
|
||||
}
|
||||
|
||||
// ===== META =====
|
||||
const uptime = Math.round(h.uptime_sec || 0);
|
||||
const filled = h.stats?.filled ?? 0;
|
||||
const bps = Math.round(h.stats?.bits_per_sec ?? 0);
|
||||
const Bps = Math.round(h.stats?.bytes_per_sec ?? 0);
|
||||
const idle = Math.round((h.last_data_ms || 0) / 1000);
|
||||
|
||||
setMeta(
|
||||
`работа: ${uptime}s | буфер: ${filled} | ${bps} bit/s (${Bps} B/s) | idle: ${idle}s`
|
||||
);
|
||||
|
||||
// ===== HISTORY =====
|
||||
if (DOM.history) {
|
||||
DOM.history.innerHTML = arr
|
||||
.map((b, i) =>
|
||||
`[${i}] ${(b & 0xFF).toString(2).padStart(8, "0")} = ${b & 0xFF}`
|
||||
)
|
||||
.join("<br>");
|
||||
}
|
||||
|
||||
// ===== CHART =====
|
||||
const now = performance.now();
|
||||
|
||||
if (now - lastChart > 100 && DOM.canvas) {
|
||||
drawChart(DOM.canvas, state.signalHistory);
|
||||
lastChart = now;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
consecutiveErrors++;
|
||||
|
||||
if (consecutiveErrors === 1) {
|
||||
console.warn("[app] Сервер недоступен, ждём...");
|
||||
}
|
||||
|
||||
setStatus("СЕРВЕР НЕДОСТУПЕН", true);
|
||||
setBars(0, true);
|
||||
setSignal(0, true);
|
||||
|
||||
if (DOM.lastByte) {
|
||||
DOM.lastByte.textContent = "--";
|
||||
}
|
||||
|
||||
if (consecutiveErrors >= MAX_ERRORS) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = setInterval(update, 2000);
|
||||
console.log("[app] Замедлили опрос до 2с");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================= RECONNECT LOGIC =================
|
||||
function startPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
|
||||
// Если были ошибки — сразу пробуем переподключиться
|
||||
if (consecutiveErrors > 0) {
|
||||
console.log("[app] Попытка переподключения...");
|
||||
update();
|
||||
}
|
||||
|
||||
pollTimer = setInterval(update, 500);
|
||||
}
|
||||
|
||||
// Сброс при фокусе окна (пользователь вернулся — пробуем быстро)
|
||||
window.addEventListener("focus", () => {
|
||||
if (consecutiveErrors >= MAX_ERRORS) {
|
||||
console.log("[app] Окно в фокусе, ускоряем опрос");
|
||||
consecutiveErrors = 0;
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// ================= START =================
|
||||
startPolling();
|
||||
update();
|
||||
435
cmd/server/web/js/app.ts
Normal file
435
cmd/server/web/js/app.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
// web/src/app.ts - Оркестратор приложения
|
||||
|
||||
import {
|
||||
fetchHealth,
|
||||
fetchHistory
|
||||
} from "./data.js";
|
||||
import {
|
||||
state,
|
||||
addSignals,
|
||||
resetBufferTracking,
|
||||
updatePeakHolder,
|
||||
updateSignalPeakHolder,
|
||||
setPeakHolderListener
|
||||
} from "./state.js";
|
||||
import {
|
||||
initDOM,
|
||||
setServerTime,
|
||||
setStatus,
|
||||
setMeta,
|
||||
setBars,
|
||||
setSignal,
|
||||
setAudioIndicator,
|
||||
setSoundLevel,
|
||||
DOM,
|
||||
setChannelValue,
|
||||
setChannelCurrentValue,
|
||||
channelElements
|
||||
} from "./ui.js";
|
||||
import { initAccordion } from "./accordion.js";
|
||||
import { drawChart } from "./chart.js";
|
||||
import { initResize } from "./layout.js";
|
||||
import { initCamera } from "./camera.js";
|
||||
import { audioEngine } from "./audio.js";
|
||||
|
||||
let lastChart: number = 0;
|
||||
let connectionLost: boolean = false;
|
||||
let consecutiveErrors: number = 0;
|
||||
const MAX_ERRORS: number = 3;
|
||||
let pollTimer: number | null = null;
|
||||
let lastSignalLevel: number = 0;
|
||||
|
||||
// ===== ИНИЦИАЛИЗАЦИЯ РЕГУЛЯТОРА ГРОМКОСТИ =====
|
||||
function initVolumeControl(): void {
|
||||
const slider = document.getElementById('volumeSlider') as HTMLInputElement | null;
|
||||
const trackFill = document.getElementById('volumeTrackFill') as HTMLElement | null;
|
||||
const indicator = document.getElementById('audio-indicator');
|
||||
const wrapper = document.getElementById('audioWrapper');
|
||||
|
||||
if (!slider || !trackFill || !indicator) return;
|
||||
|
||||
// Функция обновления заливки
|
||||
function updateTrackFill(volume: number): void {
|
||||
if (trackFill) {
|
||||
trackFill.style.width = `${volume}%`;
|
||||
}
|
||||
}
|
||||
|
||||
// Функция обновления индикатора в зависимости от громкости и уровня сигнала
|
||||
function updateIndicator(volume: number, signalLevel: number): void {
|
||||
if (!indicator) return;
|
||||
|
||||
if (volume === 0) {
|
||||
indicator.textContent = '🔇';
|
||||
indicator.classList.add('muted');
|
||||
indicator.classList.remove('active');
|
||||
} else if (signalLevel > 0) {
|
||||
indicator.textContent = '🔊';
|
||||
indicator.classList.remove('muted');
|
||||
indicator.classList.add('active');
|
||||
} else {
|
||||
indicator.textContent = '🔇';
|
||||
indicator.classList.add('muted');
|
||||
indicator.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Устанавливаем начальное состояние (звук выключен)
|
||||
audioEngine.setVolume(0);
|
||||
updateTrackFill(0);
|
||||
lastSignalLevel = 0;
|
||||
updateIndicator(0, 0);
|
||||
|
||||
// Обработчик изменения громкости
|
||||
slider.addEventListener('input', () => {
|
||||
const volume = parseInt(slider.value);
|
||||
|
||||
updateTrackFill(volume);
|
||||
audioEngine.setVolume(volume);
|
||||
|
||||
if (volume === 0) {
|
||||
// Громкость 0 - звук выключен
|
||||
updateIndicator(0, 0);
|
||||
if (lastSignalLevel > 0) {
|
||||
audioEngine.updateLevel(0);
|
||||
}
|
||||
} else {
|
||||
// Громкость > 0 - показываем состояние сигнала
|
||||
updateIndicator(volume, lastSignalLevel);
|
||||
if (lastSignalLevel > 0) {
|
||||
audioEngine.updateLevel(lastSignalLevel);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Для тач-устройств
|
||||
if (wrapper) {
|
||||
wrapper.addEventListener('click', (e) => {
|
||||
const control = document.getElementById('volumeControl');
|
||||
if (control && window.innerWidth <= 768) {
|
||||
control.classList.toggle('visible');
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const control = document.getElementById('volumeControl');
|
||||
if (control && window.innerWidth <= 768) {
|
||||
if (!wrapper.contains(e.target as Node)) {
|
||||
control.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Сохраняем настройки
|
||||
const savedVolume = localStorage.getItem('gport_volume');
|
||||
if (savedVolume) {
|
||||
const vol = parseInt(savedVolume);
|
||||
if (vol >= 0 && vol <= 100) {
|
||||
slider.value = String(vol);
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
}
|
||||
}
|
||||
|
||||
slider.addEventListener('change', () => {
|
||||
localStorage.setItem('gport_volume', slider.value);
|
||||
});
|
||||
}
|
||||
|
||||
// ================= INIT =================
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
initDOM();
|
||||
initAccordion();
|
||||
initResize();
|
||||
initCamera();
|
||||
initVolumeControl();
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
audioEngine.updateLevel(0);
|
||||
}, { once: true });
|
||||
|
||||
setPeakHolderListener(() => {
|
||||
if (!connectionLost) {
|
||||
setBars(state.peakValue);
|
||||
setSignal(state.peakSignalLevel);
|
||||
|
||||
const signalLevel = state.peakSignalLevel;
|
||||
const currentVolume = audioEngine.getVolume();
|
||||
|
||||
if (signalLevel !== lastSignalLevel) {
|
||||
lastSignalLevel = signalLevel;
|
||||
|
||||
// Обновляем индикатор в зависимости от громкости и уровня сигнала
|
||||
const indicator = document.getElementById('audio-indicator');
|
||||
if (indicator) {
|
||||
if (currentVolume > 0 && signalLevel > 0) {
|
||||
indicator.textContent = '🔊';
|
||||
indicator.classList.remove('muted');
|
||||
indicator.classList.add('active');
|
||||
audioEngine.updateLevel(signalLevel);
|
||||
} else if (currentVolume > 0 && signalLevel === 0) {
|
||||
indicator.textContent = '🔇';
|
||||
indicator.classList.add('muted');
|
||||
indicator.classList.remove('active');
|
||||
audioEngine.updateLevel(0);
|
||||
} else {
|
||||
indicator.textContent = '🔇';
|
||||
indicator.classList.add('muted');
|
||||
indicator.classList.remove('active');
|
||||
audioEngine.updateLevel(0);
|
||||
}
|
||||
}
|
||||
setAudioIndicator(signalLevel > 0 && currentVolume > 0, signalLevel);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const closeBtn = document.getElementById('camCloseBtn');
|
||||
const toggleBtn = document.getElementById('camToggle');
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
if (toggleBtn) {
|
||||
toggleBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
const leftPanel = document.getElementById("leftPanel");
|
||||
const rightPanel = document.getElementById("rightPanel");
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'c':
|
||||
const camBtn = document.getElementById('camToggle');
|
||||
if (camBtn) camBtn.click();
|
||||
break;
|
||||
case 'f':
|
||||
if (leftPanel) {
|
||||
leftPanel.style.width = "100%";
|
||||
if (rightPanel) rightPanel.style.display = "none";
|
||||
}
|
||||
break;
|
||||
case 'escape':
|
||||
if (rightPanel) rightPanel.style.display = "flex";
|
||||
if (leftPanel && rightPanel) {
|
||||
const total = window.innerWidth;
|
||||
const leftWidth = total * 0.6;
|
||||
leftPanel.style.flex = `0 0 ${leftWidth}px`;
|
||||
rightPanel.style.flex = `0 0 ${total - leftWidth}px`;
|
||||
}
|
||||
break;
|
||||
case 'm':
|
||||
if (lastSignalLevel > 0 && audioEngine.getVolume() > 0) {
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
window._mutedLevel = lastSignalLevel;
|
||||
lastSignalLevel = 0;
|
||||
} else {
|
||||
const level = window._mutedLevel || state.peakSignalLevel;
|
||||
if (level > 0 && audioEngine.getVolume() > 0) {
|
||||
audioEngine.updateLevel(level);
|
||||
lastSignalLevel = level;
|
||||
setAudioIndicator(true, level);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ================= UPDATE =================
|
||||
async function update(): Promise<void> {
|
||||
try {
|
||||
const [health, history] = await Promise.all([
|
||||
fetchHealth(),
|
||||
fetchHistory()
|
||||
]);
|
||||
|
||||
consecutiveErrors = 0;
|
||||
|
||||
if (health.server_time) {
|
||||
setServerTime(health.server_time);
|
||||
}
|
||||
|
||||
setSoundLevel(health.sound_level ?? 0);
|
||||
|
||||
const isOnline = health.pipe_alive && health.has_data;
|
||||
|
||||
if (isOnline) {
|
||||
setStatus("На связи");
|
||||
connectionLost = false;
|
||||
} else {
|
||||
setStatus("Нет связи", true);
|
||||
if (!connectionLost) resetBufferTracking();
|
||||
connectionLost = true;
|
||||
if (lastSignalLevel > 0) {
|
||||
lastSignalLevel = 0;
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
}
|
||||
}
|
||||
|
||||
const arr = Array.isArray(history.bytes) ? history.bytes : [];
|
||||
const raw = arr[arr.length - 1] ?? 0;
|
||||
|
||||
if (arr.length > 0 && isOnline) {
|
||||
const bufferSize = health.stats?.filled ?? 0;
|
||||
addSignals(arr, bufferSize);
|
||||
}
|
||||
|
||||
// ===== НАХОДИМ МАКСИМУМ ИЗ БУФЕРА =====
|
||||
let maxSignal = 0;
|
||||
let maxLevel = 0;
|
||||
let currentSignal = 0;
|
||||
let currentLevel = 0;
|
||||
|
||||
if (isOnline && arr.length > 0) {
|
||||
for (const byte of arr) {
|
||||
const signal = byte & 0x3F;
|
||||
const amp = (byte >> 6) & 0x3;
|
||||
|
||||
if (signal > maxSignal) {
|
||||
maxSignal = signal;
|
||||
}
|
||||
if (amp > maxLevel) {
|
||||
maxLevel = amp;
|
||||
}
|
||||
}
|
||||
|
||||
currentSignal = raw & 0x3F;
|
||||
currentLevel = (raw >> 6) & 0x3;
|
||||
}
|
||||
|
||||
const percent = (maxSignal / 63) * 100;
|
||||
|
||||
// ===== ОБНОВЛЯЕМ КАНАЛЫ =====
|
||||
if (isOnline && arr.length > 0) {
|
||||
setChannelValue(0, maxSignal, maxLevel);
|
||||
setChannelCurrentValue(0, currentSignal, currentLevel);
|
||||
|
||||
for (let i = 1; i < 8; i++) {
|
||||
setChannelValue(i, 0, 0);
|
||||
setChannelCurrentValue(i, 0, 0);
|
||||
if (channelElements[i]) {
|
||||
channelElements[i].item.classList.remove('channel-active');
|
||||
}
|
||||
}
|
||||
|
||||
if (channelElements[0]) {
|
||||
channelElements[0].item.classList.add('channel-active');
|
||||
}
|
||||
|
||||
if (DOM.lastByte) {
|
||||
DOM.lastByte.textContent = currentSignal.toString();
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
setChannelValue(i, 0, 0);
|
||||
setChannelCurrentValue(i, 0, 0);
|
||||
if (channelElements[i]) {
|
||||
channelElements[i].item.classList.remove('channel-active');
|
||||
}
|
||||
}
|
||||
if (DOM.lastByte) {
|
||||
DOM.lastByte.textContent = '--';
|
||||
}
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
updatePeakHolder(percent);
|
||||
updateSignalPeakHolder(maxLevel);
|
||||
} else {
|
||||
setBars(0, true);
|
||||
setSignal(0, true);
|
||||
if (lastSignalLevel > 0) {
|
||||
lastSignalLevel = 0;
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
}
|
||||
}
|
||||
|
||||
const uptime = Math.round(health.uptime_sec || 0);
|
||||
const filled = health.stats?.filled ?? 0;
|
||||
const Bps = Math.round(health.stats?.bytes_per_sec ?? 0);
|
||||
|
||||
setMeta(`Работа: ${uptime}s | Буфер: ${filled} | Приём: ${Bps} B/s`);
|
||||
|
||||
// ===== ИСТОРИЯ: НОВЫЕ СВЕРХУ =====
|
||||
if (DOM.history) {
|
||||
if (arr.length === 0) {
|
||||
DOM.history.innerHTML = 'Нет данных';
|
||||
} else {
|
||||
const reversed = [...arr].reverse();
|
||||
DOM.history.innerHTML = reversed
|
||||
.map((b, i) => {
|
||||
const originalIndex = arr.length - 1 - i;
|
||||
const binary = (b & 0xFF).toString(2).padStart(8, "0");
|
||||
return `[${originalIndex}] ${binary} = ${b & 0xFF}`;
|
||||
})
|
||||
.join("<br>");
|
||||
}
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
if (now - lastChart > 100 && DOM.canvas) {
|
||||
drawChart(DOM.canvas, state.signalHistory);
|
||||
lastChart = now;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
consecutiveErrors++;
|
||||
|
||||
setStatus("СЕРВЕР НЕДОСТУПЕН", true);
|
||||
setServerTime("--:--:--");
|
||||
setBars(0, true);
|
||||
setSignal(0, true);
|
||||
setSoundLevel(0);
|
||||
resetBufferTracking();
|
||||
|
||||
if (lastSignalLevel > 0) {
|
||||
lastSignalLevel = 0;
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
}
|
||||
|
||||
if (DOM.lastByte) DOM.lastByte.textContent = "--";
|
||||
|
||||
if (DOM.history) {
|
||||
DOM.history.innerHTML = "Ошибка загрузки данных";
|
||||
}
|
||||
|
||||
if (consecutiveErrors >= MAX_ERRORS && pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
if (consecutiveErrors > 0) update();
|
||||
pollTimer = window.setInterval(update, 500);
|
||||
}
|
||||
|
||||
window.addEventListener("focus", () => {
|
||||
if (consecutiveErrors >= MAX_ERRORS) {
|
||||
consecutiveErrors = 0;
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
audioEngine.dispose();
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_mutedLevel?: number;
|
||||
}
|
||||
}
|
||||
|
||||
startPolling();
|
||||
update();
|
||||
307
cmd/server/web/js/audio.ts
Normal file
307
cmd/server/web/js/audio.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
// web/src/audio.ts - Звуковой движок для сигнала
|
||||
|
||||
export class AudioEngine {
|
||||
private audioContext: AudioContext | null = null;
|
||||
private oscillator: OscillatorNode | null = null;
|
||||
private gainNode: GainNode | null = null;
|
||||
private isPlaying: boolean = false;
|
||||
private currentLevel: number = 0;
|
||||
private currentFrequency: number = 800;
|
||||
private fadeTimeout: number | null = null;
|
||||
private readonly FADE_TIME: number = 0.05;
|
||||
private readonly FREQUENCY_MAP: Record<number, number> = {
|
||||
1: 600, // Уровень 1 → 600 Гц
|
||||
2: 1000, // Уровень 2 → 1000 Гц
|
||||
3: 1200 // Уровень 3 → 1200 Гц
|
||||
};
|
||||
|
||||
// ===== НОВОЕ: громкость (0-100) =====
|
||||
private volume: number = 0;
|
||||
private maxVolume: number = 0.45; // 45% максимум
|
||||
|
||||
constructor() {
|
||||
// Инициализация при первом использовании
|
||||
}
|
||||
|
||||
/**
|
||||
* Установка громкости (0-100)
|
||||
*/
|
||||
public setVolume(volume: number): void {
|
||||
this.volume = Math.max(0, Math.min(100, volume));
|
||||
this.updateVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущей громкости (0-100)
|
||||
*/
|
||||
public getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Применение громкости к усилителю
|
||||
*/
|
||||
private updateVolume(): void {
|
||||
if (!this.gainNode) return;
|
||||
|
||||
const normalized = (this.volume / 100) * this.maxVolume;
|
||||
const currentLevel = this.currentLevel;
|
||||
|
||||
// Если уровень 0 или громкость 0 - выключаем звук
|
||||
if (currentLevel === 0 || this.volume === 0) {
|
||||
this.gainNode.gain.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Рассчитываем громкость на основе уровня и общей громкости
|
||||
const volumeMap: Record<number, number> = {
|
||||
1: 0.10 * (normalized / this.maxVolume),
|
||||
2: 0.25 * (normalized / this.maxVolume),
|
||||
3: 0.45 * (normalized / this.maxVolume)
|
||||
};
|
||||
|
||||
const targetVolume = Math.min(volumeMap[currentLevel] || 0, this.maxVolume * (this.volume / 100));
|
||||
|
||||
try {
|
||||
const currentTime = this.audioContext?.currentTime || 0;
|
||||
this.gainNode.gain.exponentialRampToValueAtTime(
|
||||
Math.max(targetVolume, 0.001),
|
||||
currentTime + this.FADE_TIME
|
||||
);
|
||||
} catch (error) {
|
||||
this.gainNode.gain.value = targetVolume;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ОСТАЛЬНЫЕ МЕТОДЫ =====
|
||||
|
||||
private async initAudio(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.audioContext) {
|
||||
const AudioContextClass = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
this.audioContext = new AudioContextClass();
|
||||
}
|
||||
|
||||
const context = this.audioContext;
|
||||
if (!context) return false;
|
||||
|
||||
if (context.state === 'suspended') {
|
||||
await context.resume();
|
||||
}
|
||||
|
||||
return context.state === 'running';
|
||||
} catch (error) {
|
||||
console.warn('[Audio] Ошибка инициализации:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение частоты для уровня
|
||||
*/
|
||||
private getFrequencyForLevel(level: number): number {
|
||||
return this.FREQUENCY_MAP[level] || 800;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление частоты осциллятора
|
||||
*/
|
||||
private updateFrequency(frequency: number): void {
|
||||
if (!this.oscillator) return;
|
||||
|
||||
try {
|
||||
const currentTime = this.audioContext?.currentTime || 0;
|
||||
this.oscillator.frequency.exponentialRampToValueAtTime(
|
||||
frequency,
|
||||
currentTime + this.FADE_TIME
|
||||
);
|
||||
this.currentFrequency = frequency;
|
||||
} catch (error) {
|
||||
this.oscillator.frequency.value = frequency;
|
||||
this.currentFrequency = frequency;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание звукового графа
|
||||
*/
|
||||
private createSoundGraph(level: number): void {
|
||||
if (!this.audioContext) return;
|
||||
|
||||
this.stopSound();
|
||||
|
||||
const frequency = this.getFrequencyForLevel(level);
|
||||
this.currentFrequency = frequency;
|
||||
|
||||
this.oscillator = this.audioContext.createOscillator();
|
||||
this.oscillator.type = 'sine';
|
||||
this.oscillator.frequency.value = frequency;
|
||||
|
||||
this.gainNode = this.audioContext.createGain();
|
||||
this.gainNode.gain.value = 0;
|
||||
|
||||
this.oscillator.connect(this.gainNode);
|
||||
this.gainNode.connect(this.audioContext.destination);
|
||||
|
||||
this.oscillator.start();
|
||||
this.isPlaying = true;
|
||||
|
||||
// Применяем текущую громкость
|
||||
this.updateVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление уровня сигнала
|
||||
* @param level - уровень сигнала (0-3)
|
||||
*/
|
||||
public async updateLevel(level: number): Promise<void> {
|
||||
if (!this.audioContext) {
|
||||
const initialized = await this.initAudio();
|
||||
if (!initialized) return;
|
||||
}
|
||||
|
||||
const context = this.audioContext;
|
||||
if (!context) return;
|
||||
|
||||
if (context.state !== 'running') {
|
||||
try {
|
||||
await context.resume();
|
||||
} catch (error) {
|
||||
console.warn('[Audio] Ошибка возобновления контекста:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newLevel = Math.max(0, Math.min(3, Math.round(level)));
|
||||
|
||||
if (newLevel === this.currentLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentLevel = newLevel;
|
||||
|
||||
// Если громкость 0 - ничего не включаем
|
||||
if (this.volume === 0) {
|
||||
this.stopSound();
|
||||
return;
|
||||
}
|
||||
|
||||
const newFrequency = this.getFrequencyForLevel(newLevel);
|
||||
|
||||
if (newLevel > 0 && !this.oscillator) {
|
||||
this.createSoundGraph(newLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newLevel === 0) {
|
||||
this.fadeOut();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isPlaying || !this.oscillator) {
|
||||
this.createSoundGraph(newLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newFrequency !== this.currentFrequency) {
|
||||
this.updateFrequency(newFrequency);
|
||||
}
|
||||
|
||||
this.updateVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Плавное затухание и остановка
|
||||
*/
|
||||
private fadeOut(): void {
|
||||
if (!this.gainNode || !this.audioContext) return;
|
||||
|
||||
if (this.fadeTimeout) {
|
||||
clearTimeout(this.fadeTimeout);
|
||||
this.fadeTimeout = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentTime = this.audioContext.currentTime;
|
||||
this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2);
|
||||
} catch (error) {
|
||||
this.gainNode.gain.value = 0;
|
||||
}
|
||||
|
||||
this.fadeTimeout = window.setTimeout(() => {
|
||||
this.stopSound();
|
||||
this.fadeTimeout = null;
|
||||
}, this.FADE_TIME * 2 * 1000 + 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Немедленная остановка звука
|
||||
*/
|
||||
private stopSound(): void {
|
||||
if (this.oscillator) {
|
||||
try {
|
||||
this.oscillator.stop();
|
||||
this.oscillator.disconnect();
|
||||
} catch (e) {}
|
||||
this.oscillator = null;
|
||||
}
|
||||
|
||||
if (this.gainNode) {
|
||||
try {
|
||||
this.gainNode.disconnect();
|
||||
} catch (e) {}
|
||||
this.gainNode = null;
|
||||
}
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
if (this.fadeTimeout) {
|
||||
clearTimeout(this.fadeTimeout);
|
||||
this.fadeTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПУБЛИЧНЫЕ МЕТОДЫ
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Проверка, активен ли звук
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isPlaying && this.currentLevel > 0 && this.volume > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущей частоты
|
||||
*/
|
||||
public getCurrentFrequency(): number {
|
||||
return this.currentFrequency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущего уровня
|
||||
*/
|
||||
public getCurrentLevel(): number {
|
||||
return this.currentLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная остановка и освобождение ресурсов
|
||||
*/
|
||||
public async dispose(): Promise<void> {
|
||||
this.stopSound();
|
||||
if (this.audioContext) {
|
||||
try {
|
||||
await this.audioContext.close();
|
||||
} catch (e) {}
|
||||
this.audioContext = null;
|
||||
}
|
||||
this.currentLevel = 0;
|
||||
this.currentFrequency = 800;
|
||||
this.volume = 0;
|
||||
console.log('[Audio] Ресурсы освобождены');
|
||||
}
|
||||
}
|
||||
|
||||
export const audioEngine = new AudioEngine();
|
||||
165
cmd/server/web/js/camera.ts
Normal file
165
cmd/server/web/js/camera.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// camera.ts
|
||||
import { updateResizerVisibility, openRightPanel, closeRightPanel } from './layout.js';
|
||||
|
||||
let rightPanel: HTMLElement | null = null;
|
||||
let camImage: HTMLImageElement | null = null;
|
||||
let crosshair: HTMLElement | null = null;
|
||||
let isOpen: boolean = false;
|
||||
let streamCheckInterval: number | null = null;
|
||||
let resizer: HTMLElement | null = null;
|
||||
|
||||
export function initCamera(): void {
|
||||
rightPanel = document.getElementById("rightPanel");
|
||||
camImage = document.getElementById("camImage") as HTMLImageElement;
|
||||
crosshair = document.getElementById("crosshair");
|
||||
resizer = document.getElementById("dragBar");
|
||||
|
||||
const btn = document.getElementById("camToggle");
|
||||
|
||||
const streamUrl = "/api/cam";
|
||||
|
||||
function open(): void {
|
||||
if (isOpen) return;
|
||||
|
||||
isOpen = true;
|
||||
|
||||
// Открываем правую панель через layout
|
||||
openRightPanel();
|
||||
|
||||
// Показываем перекрестие
|
||||
if (crosshair) {
|
||||
crosshair.style.display = "block";
|
||||
crosshair.style.opacity = "0.5";
|
||||
}
|
||||
|
||||
if (camImage) {
|
||||
camImage.src = streamUrl;
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
const textSpan = btn.querySelector('.cam-btn-text');
|
||||
if (textSpan) {
|
||||
textSpan.textContent = 'Скрыть';
|
||||
} else {
|
||||
btn.innerHTML = 'Скрыть';
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[camera] opened");
|
||||
|
||||
// Запускаем мониторинг потока
|
||||
startStreamMonitoring();
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (!isOpen) return;
|
||||
|
||||
isOpen = false;
|
||||
|
||||
// Скрываем перекрестие
|
||||
if (crosshair) {
|
||||
crosshair.style.display = "none";
|
||||
}
|
||||
|
||||
// Останавливаем поток
|
||||
if (camImage) {
|
||||
camImage.src = "";
|
||||
}
|
||||
|
||||
// Закрываем правую панель через layout
|
||||
closeRightPanel();
|
||||
|
||||
if (btn) {
|
||||
const textSpan = btn.querySelector('.cam-btn-text');
|
||||
if (textSpan) {
|
||||
textSpan.textContent = 'Камера';
|
||||
} else {
|
||||
btn.innerHTML = 'Камера';
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[camera] closed");
|
||||
|
||||
// Останавливаем мониторинг
|
||||
stopStreamMonitoring();
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
if (camImage) {
|
||||
camImage.onload = () => {
|
||||
console.log("[camera] stream started");
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = '1';
|
||||
}
|
||||
};
|
||||
|
||||
camImage.onerror = (e) => {
|
||||
console.error("[camera] stream error", e);
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = '0.3';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
btn?.addEventListener("click", toggle);
|
||||
|
||||
// Мониторинг состояния потока
|
||||
function startStreamMonitoring(): void {
|
||||
stopStreamMonitoring();
|
||||
streamCheckInterval = window.setInterval(() => {
|
||||
if (isOpen && camImage) {
|
||||
if (!camImage.complete || camImage.naturalWidth === 0) {
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = '0.3';
|
||||
}
|
||||
} else {
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopStreamMonitoring(): void {
|
||||
if (streamCheckInterval) {
|
||||
clearInterval(streamCheckInterval);
|
||||
streamCheckInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПУБЛИЧНЫЕ ФУНКЦИИ (используются в других модулях)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Показать/скрыть перекрестие на видео
|
||||
* @param show - показывать или скрыть
|
||||
*/
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export function showCrosshair(show: boolean = true): void {
|
||||
const crosshairElem = document.getElementById("crosshair");
|
||||
if (crosshairElem) {
|
||||
crosshairElem.style.display = show ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить прозрачность перекрестия
|
||||
* @param opacity - значение прозрачности (0-1)
|
||||
*/
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export function setCrosshairOpacity(opacity: string | number): void {
|
||||
const crosshairElem = document.getElementById("crosshair");
|
||||
if (crosshairElem) {
|
||||
crosshairElem.style.opacity = String(opacity);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// chart.js
|
||||
export function drawChart(canvas, history) {
|
||||
if (!canvas || !history?.length) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const displayHeight = rect.height || 200; // увеличил для подписей
|
||||
const displayWidth = rect.width || 800;
|
||||
|
||||
canvas.width = displayWidth;
|
||||
canvas.height = displayHeight;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
// ===== ОТСТУПЫ ДЛЯ ПОДПИСЕЙ =====
|
||||
const padLeft = 50; // место под значения Y слева
|
||||
const padRight = 20; // отступ справа
|
||||
const padTop = 15; // отступ сверху
|
||||
const padBottom = 30; // отступ снизу для подписей X
|
||||
|
||||
const plotW = w - padLeft - padRight;
|
||||
const plotH = h - padTop - padBottom;
|
||||
|
||||
// ===== ФОН ГРАФИКА =====
|
||||
ctx.fillStyle = "#0a0f0a";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// ===== СЕТКА =====
|
||||
const maxVal = 63;
|
||||
const gridLines = 4; // 4 линии + 0 = 5 уровней: 0, 16, 32, 48, 64
|
||||
|
||||
ctx.strokeStyle = "#0d2a0d";
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.setLineDash([4, 4]);
|
||||
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const val = (maxVal / gridLines) * i;
|
||||
const y = padTop + plotH - (val / maxVal) * plotH;
|
||||
|
||||
// Линия сетки
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft, y);
|
||||
ctx.lineTo(w - padRight, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Подпись значения слева
|
||||
ctx.fillStyle = "#00ff88";
|
||||
ctx.font = "11px monospace";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(Math.round(val), padLeft - 8, y + 4);
|
||||
}
|
||||
|
||||
// Вертикальные линии (временные метки)
|
||||
const timeMarks = 5;
|
||||
ctx.setLineDash([4, 4]);
|
||||
for (let i = 0; i <= timeMarks; i++) {
|
||||
const x = padLeft + (plotW / timeMarks) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, padTop + plotH);
|
||||
ctx.stroke();
|
||||
|
||||
// Подпись снизу
|
||||
const pct = Math.round((i / timeMarks) * 100);
|
||||
ctx.fillStyle = "#008844";
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(`${100 - pct}%`, x, padTop + plotH + 16);
|
||||
}
|
||||
|
||||
ctx.setLineDash([]); // сброс пунктира
|
||||
|
||||
// ===== ПОДПИСИ ОСЕЙ =====
|
||||
ctx.fillStyle = "#00ff88";
|
||||
ctx.font = "bold 12px monospace";
|
||||
|
||||
// Y
|
||||
ctx.save();
|
||||
ctx.translate(12, padTop + plotH / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("ЗНАЧЕНИЕ (0–63)", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// X
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("ВРЕМЯ ← новые", w / 2, h - 4);
|
||||
|
||||
// ===== РИСУЕМ СИГНАЛ =====
|
||||
const hasSignal = history.some(v => v > 0);
|
||||
|
||||
if (!hasSignal) {
|
||||
ctx.fillStyle = "#ff4444";
|
||||
ctx.font = "14px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Ожидание сигнала...", w / 2, h / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Линия графика
|
||||
ctx.strokeStyle = "#e6852d";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = "#e6852d";
|
||||
ctx.shadowBlur = 3;
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
const stepX = plotW / Math.max(history.length - 1, 1);
|
||||
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const x = padLeft + i * stepX;
|
||||
const y = padTop + plotH - (history[i] / maxVal) * plotH;
|
||||
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// ===== ПОСЛЕДНЯЯ ТОЧКА =====
|
||||
if (history.length > 0) {
|
||||
const lastIdx = history.length - 1;
|
||||
const lastX = padLeft + lastIdx * stepX;
|
||||
const lastY = padTop + plotH - (history[lastIdx] / maxVal) * plotH;
|
||||
const lastVal = history[lastIdx];
|
||||
|
||||
// Кружок на последней точке
|
||||
ctx.fillStyle = "#c46b1f";
|
||||
ctx.beginPath();
|
||||
ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Подпись значения над точкой
|
||||
ctx.fillStyle = "#00ff88";
|
||||
ctx.font = "bold 12px monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`${lastVal}`, lastX + 8, lastY - 8);
|
||||
|
||||
// Карточка статистики в правом верхнем углу
|
||||
const avg = Math.round(history.reduce((a, b) => a + b, 0) / history.length);
|
||||
const peak = Math.max(...history);
|
||||
const min = Math.min(...history);
|
||||
|
||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
||||
ctx.fillRect(w - 140, padTop, 130, 55);
|
||||
|
||||
ctx.fillStyle = "#00ff88";
|
||||
ctx.font = "10px monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`Пик: ${peak}`, w - 130, padTop + 14);
|
||||
ctx.fillText(`Сред: ${avg}`, w - 130, padTop + 28);
|
||||
ctx.fillText(`Мин: ${min}`, w - 130, padTop + 42);
|
||||
ctx.fillText(`Всего: ${history.length}`, w - 130, padTop + 56);
|
||||
}
|
||||
}
|
||||
138
cmd/server/web/js/chart.ts
Normal file
138
cmd/server/web/js/chart.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// chart.js
|
||||
export function drawChart(canvas: HTMLCanvasElement, history: number[]): void {
|
||||
if (!canvas || !history?.length) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const displayHeight = rect.height || 200;
|
||||
canvas.width = rect.width || 800;
|
||||
canvas.height = displayHeight;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
const padLeft = 45;
|
||||
const padRight = 15;
|
||||
const padTop = 10;
|
||||
const padBottom = 25;
|
||||
const plotW = w - padLeft - padRight;
|
||||
const plotH = h - padTop - padBottom;
|
||||
|
||||
// Фон
|
||||
ctx.fillStyle = "#0a0f0a";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Сетка
|
||||
const maxVal = 63;
|
||||
const gridLines = 4;
|
||||
|
||||
ctx.strokeStyle = "#0d2a0d";
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.setLineDash([4, 4]);
|
||||
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const val = (maxVal / gridLines) * i;
|
||||
const y = padTop + plotH - (val / maxVal) * plotH;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft, y);
|
||||
ctx.lineTo(w - padRight, y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#00ff88";
|
||||
ctx.font = "11px monospace";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4);
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Подписи осей
|
||||
ctx.fillStyle = "#00ff88";
|
||||
ctx.font = "bold 11px monospace";
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(12, padTop + plotH / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("ЗНАЧЕНИЕ", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4);
|
||||
|
||||
const hasSignal = history.some(v => v > 0);
|
||||
|
||||
if (!hasSignal) {
|
||||
ctx.fillStyle = "#ff4444";
|
||||
ctx.font = "14px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Ожидание сигнала...", w / 2, h / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
let plotData = history;
|
||||
if (history.length > plotW) {
|
||||
const step = history.length / plotW;
|
||||
plotData = [];
|
||||
for (let i = 0; i < plotW; i++) {
|
||||
const idx = Math.floor(i * step);
|
||||
plotData.push(history[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "#e6852d";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = "#e6852d";
|
||||
ctx.shadowBlur = 3;
|
||||
|
||||
ctx.beginPath();
|
||||
const stepX = plotW / Math.max(plotData.length - 1, 1);
|
||||
|
||||
for (let i = 0; i < plotData.length; i++) {
|
||||
const x = padLeft + i * stepX;
|
||||
const y = padTop + plotH - (plotData[i] / maxVal) * plotH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
if (history.length > 0) {
|
||||
const lastVal = history[history.length - 1];
|
||||
const lastX = padLeft + (plotData.length - 1) * stepX;
|
||||
const lastY = padTop + plotH - (lastVal / maxVal) * plotH;
|
||||
|
||||
ctx.fillStyle = "#c46b1f";
|
||||
ctx.beginPath();
|
||||
ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const labelText = `${lastVal}`;
|
||||
const labelWidth = ctx.measureText(labelText).width;
|
||||
|
||||
ctx.fillStyle = "rgba(0,0,0,0.8)";
|
||||
ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18);
|
||||
|
||||
ctx.fillStyle = "#00ff88";
|
||||
ctx.font = "bold 12px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(labelText, lastX, lastY - 8);
|
||||
|
||||
const peak = Math.max(...history);
|
||||
const min = Math.min(...history);
|
||||
|
||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
||||
ctx.fillRect(w - 120, padTop, 110, 42);
|
||||
|
||||
ctx.fillStyle = "#00ff88";
|
||||
ctx.font = "10px monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`Пик: ${peak}`, w - 110, padTop + 14);
|
||||
ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28);
|
||||
ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// data.js (API слой)
|
||||
export async function fetchHealth() {
|
||||
const r = await fetch("/api/health");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchHistory() {
|
||||
const r = await fetch("/api/history");
|
||||
return r.json();
|
||||
}
|
||||
32
cmd/server/web/js/data.ts
Normal file
32
cmd/server/web/js/data.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// data.js (API слой)
|
||||
// Типы ответов от сервера
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
uptime_sec: number;
|
||||
last_data_ms: number;
|
||||
pipe_alive: boolean;
|
||||
has_data: boolean;
|
||||
latest: number;
|
||||
server_time?: string;
|
||||
sound_level?: number;
|
||||
stats: {
|
||||
filled: number;
|
||||
bytes_per_sec: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
bytes: number[];
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<HealthResponse> {
|
||||
const r = await fetch("/api/health");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchHistory(): Promise<HistoryResponse> {
|
||||
const r = await fetch("/api/history");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
236
cmd/server/web/js/layout.ts
Normal file
236
cmd/server/web/js/layout.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// layout.ts
|
||||
let resizer: HTMLElement | null = null;
|
||||
let leftPanel: HTMLElement | null = null;
|
||||
let rightPanel: HTMLElement | null = null;
|
||||
let isOpen: boolean = false;
|
||||
let lastLeftWidth: number = 0;
|
||||
let lastRightWidth: number = 0;
|
||||
|
||||
export function initResize(): void {
|
||||
resizer = document.getElementById("dragBar");
|
||||
leftPanel = document.getElementById("leftPanel");
|
||||
rightPanel = document.getElementById("rightPanel");
|
||||
|
||||
if (!resizer || !leftPanel || !rightPanel) {
|
||||
console.warn("[layout] Элементы не найдены");
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем видимость при инициализации
|
||||
updateResizerVisibility();
|
||||
|
||||
let isDragging = false;
|
||||
|
||||
// Обработчик начала перетаскивания
|
||||
resizer.addEventListener("mousedown", (e: MouseEvent) => {
|
||||
// Сохраняем ссылки на элементы в локальные переменные
|
||||
const left = leftPanel;
|
||||
const right = rightPanel;
|
||||
|
||||
// Проверяем, что элементы существуют
|
||||
if (!left || !right) {
|
||||
console.warn("[layout] Панели не найдены");
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что правая панель видима
|
||||
if (right.classList.contains("hidden")) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
// Сохраняем начальные размеры
|
||||
const startX = e.clientX;
|
||||
const leftWidth = left.getBoundingClientRect().width;
|
||||
const rightWidth = right.getBoundingClientRect().width;
|
||||
const totalWidth = leftWidth + rightWidth;
|
||||
|
||||
function onMouseMove(moveEvent: MouseEvent): void {
|
||||
if (!isDragging) return;
|
||||
|
||||
// Проверяем, что элементы всё ещё существуют
|
||||
if (!left || !right) return;
|
||||
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
let newLeftWidth = leftWidth + deltaX;
|
||||
|
||||
// Ограничиваем минимальные размеры
|
||||
const minWidth = 200;
|
||||
newLeftWidth = Math.max(minWidth, Math.min(totalWidth - minWidth, newLeftWidth));
|
||||
|
||||
const newRightWidth = totalWidth - newLeftWidth;
|
||||
|
||||
left.style.flex = `0 0 ${newLeftWidth}px`;
|
||||
right.style.flex = `0 0 ${newRightWidth}px`;
|
||||
|
||||
// Сохраняем последние размеры
|
||||
lastLeftWidth = newLeftWidth;
|
||||
lastRightWidth = newRightWidth;
|
||||
}
|
||||
|
||||
function onMouseUp(): void {
|
||||
isDragging = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
|
||||
// Обработчик изменения размера окна
|
||||
window.addEventListener("resize", () => {
|
||||
handleWindowResize();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка изменения размера окна
|
||||
*/
|
||||
function handleWindowResize(): void {
|
||||
if (!leftPanel || !rightPanel) return;
|
||||
|
||||
// Если правая панель видима, пересчитываем размеры
|
||||
if (!rightPanel.classList.contains("hidden")) {
|
||||
// Если есть сохраненные размеры, используем их пропорционально
|
||||
if (lastLeftWidth > 0 && lastRightWidth > 0) {
|
||||
const totalWidth = window.innerWidth;
|
||||
const ratio = lastLeftWidth / (lastLeftWidth + lastRightWidth);
|
||||
const newLeftWidth = totalWidth * ratio;
|
||||
const newRightWidth = totalWidth - newLeftWidth;
|
||||
|
||||
// Ограничиваем минимальные размеры
|
||||
const minWidth = 200;
|
||||
const finalLeftWidth = Math.max(minWidth, Math.min(totalWidth - minWidth, newLeftWidth));
|
||||
const finalRightWidth = totalWidth - finalLeftWidth;
|
||||
|
||||
leftPanel.style.flex = `0 0 ${finalLeftWidth}px`;
|
||||
rightPanel.style.flex = `0 0 ${finalRightWidth}px`;
|
||||
|
||||
lastLeftWidth = finalLeftWidth;
|
||||
lastRightWidth = finalRightWidth;
|
||||
} else {
|
||||
// Если нет сохраненных размеров, делим пополам
|
||||
const totalWidth = window.innerWidth;
|
||||
const halfWidth = totalWidth / 2;
|
||||
leftPanel.style.flex = `0 0 ${halfWidth}px`;
|
||||
rightPanel.style.flex = `0 0 ${halfWidth}px`;
|
||||
|
||||
lastLeftWidth = halfWidth;
|
||||
lastRightWidth = halfWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление видимости разделителя в зависимости от состояния правой панели
|
||||
*/
|
||||
export function updateResizerVisibility(): void {
|
||||
if (!resizer || !rightPanel) return;
|
||||
|
||||
// Если правая панель скрыта - скрываем разделитель
|
||||
if (rightPanel.classList.contains("hidden")) {
|
||||
resizer.classList.add("hidden");
|
||||
} else {
|
||||
resizer.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыть правую панель
|
||||
*/
|
||||
export function openRightPanel(): void {
|
||||
if (!leftPanel || !rightPanel) return;
|
||||
|
||||
// Убираем класс hidden
|
||||
rightPanel.classList.remove("hidden");
|
||||
|
||||
// Обновляем разделитель
|
||||
updateResizerVisibility();
|
||||
|
||||
// Устанавливаем размеры
|
||||
const totalWidth = window.innerWidth;
|
||||
const halfWidth = totalWidth / 2;
|
||||
|
||||
// Проверяем, есть ли сохраненные размеры
|
||||
if (lastLeftWidth > 0 && lastRightWidth > 0) {
|
||||
// Используем сохраненные пропорции
|
||||
const ratio = lastLeftWidth / (lastLeftWidth + lastRightWidth);
|
||||
const newLeftWidth = totalWidth * ratio;
|
||||
const newRightWidth = totalWidth - newLeftWidth;
|
||||
|
||||
const minWidth = 200;
|
||||
const finalLeftWidth = Math.max(minWidth, Math.min(totalWidth - minWidth, newLeftWidth));
|
||||
const finalRightWidth = totalWidth - finalLeftWidth;
|
||||
|
||||
leftPanel.style.flex = `0 0 ${finalLeftWidth}px`;
|
||||
rightPanel.style.flex = `0 0 ${finalRightWidth}px`;
|
||||
|
||||
lastLeftWidth = finalLeftWidth;
|
||||
lastRightWidth = finalRightWidth;
|
||||
} else {
|
||||
// Делим пополам
|
||||
leftPanel.style.flex = `0 0 ${halfWidth}px`;
|
||||
rightPanel.style.flex = `0 0 ${halfWidth}px`;
|
||||
|
||||
lastLeftWidth = halfWidth;
|
||||
lastRightWidth = halfWidth;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрыть правую панель
|
||||
*/
|
||||
export function closeRightPanel(): void {
|
||||
if (!rightPanel) return;
|
||||
|
||||
// Добавляем класс hidden
|
||||
rightPanel.classList.add("hidden");
|
||||
|
||||
// Обновляем разделитель
|
||||
updateResizerVisibility();
|
||||
|
||||
// Сбрасываем flex для левой панели
|
||||
if (leftPanel) {
|
||||
leftPanel.style.flex = "1 1 auto";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПУБЛИЧНЫЕ ФУНКЦИИ (используются в других модулях)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Установить видимость разделителя вручную
|
||||
* @param visible - показывать или скрыть
|
||||
*/
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export function setResizerVisible(visible: boolean): void {
|
||||
if (resizer) {
|
||||
if (visible) {
|
||||
resizer.classList.remove("hidden");
|
||||
} else {
|
||||
resizer.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключить состояние правой панели
|
||||
*/
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export function toggleRightPanel(): void {
|
||||
if (!rightPanel) return;
|
||||
|
||||
if (rightPanel.classList.contains("hidden")) {
|
||||
openRightPanel();
|
||||
} else {
|
||||
closeRightPanel();
|
||||
}
|
||||
}
|
||||
748
cmd/server/web/js/logs.ts
Normal file
748
cmd/server/web/js/logs.ts
Normal file
@@ -0,0 +1,748 @@
|
||||
// js/logs.ts - Логика для страницы журнала логов
|
||||
|
||||
import { drawChart } from './chart.js';
|
||||
|
||||
// ============================================
|
||||
// ТИПЫ
|
||||
// ============================================
|
||||
|
||||
interface LogFileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mod_time: string;
|
||||
time: string;
|
||||
date: string;
|
||||
hour: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface LogSample {
|
||||
ts: number;
|
||||
time: string;
|
||||
value: number;
|
||||
amplitude: number;
|
||||
signal: number;
|
||||
strength_name: string;
|
||||
}
|
||||
|
||||
interface LogDataResponse {
|
||||
filename: string;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_previous: boolean;
|
||||
has_next: boolean;
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
samples: LogSample[];
|
||||
stats: {
|
||||
total_points: number;
|
||||
amplitude_over_0: number;
|
||||
signal_over_10: number;
|
||||
};
|
||||
order: string;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
time: string;
|
||||
event: string;
|
||||
}
|
||||
|
||||
interface EventsResponse {
|
||||
events: Event[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_previous: boolean;
|
||||
has_next: boolean;
|
||||
returned: number;
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
order?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// СОСТОЯНИЕ
|
||||
// ============================================
|
||||
|
||||
interface AppState {
|
||||
currentFile: string | null;
|
||||
currentData: LogDataResponse | null;
|
||||
currentEvents: Event[];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
// Для данных
|
||||
dataPage: number;
|
||||
dataTotalPages: number;
|
||||
isLoadingData: boolean;
|
||||
}
|
||||
|
||||
const state: AppState = {
|
||||
currentFile: null,
|
||||
currentData: null,
|
||||
currentEvents: [],
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
isLoading: false,
|
||||
dataPage: 1,
|
||||
dataTotalPages: 1,
|
||||
isLoadingData: false,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const DATA_PAGE_SIZE = 100;
|
||||
|
||||
// ============================================
|
||||
// DOM ЭЛЕМЕНТЫ
|
||||
// ============================================
|
||||
|
||||
const DOM = {
|
||||
fileList: document.getElementById('fileList') as HTMLElement | null,
|
||||
logStats: document.getElementById('logStats') as HTMLElement | null,
|
||||
dataTableContainer: document.getElementById('dataTableContainer') as HTMLElement | null,
|
||||
logChart: document.getElementById('logChart') as HTMLCanvasElement | null,
|
||||
eventsContent: document.getElementById('eventsContent') as HTMLElement | null,
|
||||
serverTime: document.getElementById('server-time') as HTMLElement | null,
|
||||
|
||||
// Боковая панель
|
||||
logsSidebar: document.querySelector('.logs-sidebar') as HTMLElement | null,
|
||||
logsContainer: document.querySelector('.logs-container') as HTMLElement | null,
|
||||
|
||||
// Элементы пагинации для данных
|
||||
dataPageInfo: document.getElementById('dataPageInfo') as HTMLElement | null,
|
||||
dataPrevPageBtn: document.getElementById('dataPrevPage') as HTMLButtonElement | null,
|
||||
dataNextPageBtn: document.getElementById('dataNextPage') as HTMLButtonElement | null,
|
||||
dataFirstPageBtn: document.getElementById('dataFirstPage') as HTMLButtonElement | null,
|
||||
dataLastPageBtn: document.getElementById('dataLastPage') as HTMLButtonElement | null,
|
||||
dataPageInput: document.getElementById('dataPageInput') as HTMLInputElement | null,
|
||||
dataGoToPageBtn: document.getElementById('dataGoToPage') as HTMLButtonElement | null,
|
||||
|
||||
// Элементы пагинации для событий
|
||||
pageInfo: document.getElementById('pageInfo') as HTMLElement | null,
|
||||
prevPageBtn: document.getElementById('prevPage') as HTMLButtonElement | null,
|
||||
nextPageBtn: document.getElementById('nextPage') as HTMLButtonElement | null,
|
||||
firstPageBtn: document.getElementById('firstPage') as HTMLButtonElement | null,
|
||||
lastPageBtn: document.getElementById('lastPage') as HTMLButtonElement | null,
|
||||
pageInput: document.getElementById('pageInput') as HTMLInputElement | null,
|
||||
goToPageBtn: document.getElementById('goToPage') as HTMLButtonElement | null,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// УПРАВЛЕНИЕ БОКОВОЙ ПАНЕЛЬЮ
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Показать/скрыть боковую панель в зависимости от активной вкладки
|
||||
*/
|
||||
function toggleSidebar(show: boolean): void {
|
||||
if (!DOM.logsSidebar || !DOM.logsContainer) return;
|
||||
|
||||
if (show) {
|
||||
// Показываем панель
|
||||
DOM.logsSidebar.style.display = 'block';
|
||||
DOM.logsSidebar.style.flex = '0 0 280px';
|
||||
DOM.logsContainer.style.gap = '20px';
|
||||
} else {
|
||||
// Скрываем панель
|
||||
DOM.logsSidebar.style.display = 'none';
|
||||
DOM.logsSidebar.style.flex = '0 0 0';
|
||||
DOM.logsContainer.style.gap = '0';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ФУНКЦИИ ЗАГРУЗКИ ДАННЫХ
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Загрузка списка файлов
|
||||
*/
|
||||
async function loadFileList(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/log/files');
|
||||
const files: LogFileInfo[] = await response.json();
|
||||
|
||||
if (!DOM.fileList) return;
|
||||
|
||||
DOM.fileList.innerHTML = '';
|
||||
files.forEach((file: LogFileInfo) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-file-item';
|
||||
if (state.currentFile === file.name) {
|
||||
div.classList.add('active');
|
||||
}
|
||||
div.innerHTML = `
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-info">${file.time} | ${(file.size / 1024).toFixed(1)} KB</div>
|
||||
`;
|
||||
div.addEventListener('click', () => {
|
||||
state.dataPage = 1;
|
||||
void loadFileData(file.name, 1);
|
||||
});
|
||||
DOM.fileList?.appendChild(div);
|
||||
});
|
||||
|
||||
// Автоматически загружаем первый файл
|
||||
if (files.length > 0 && !state.currentFile) {
|
||||
state.dataPage = 1;
|
||||
await loadFileData(files[0].name, 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Logs] Ошибка загрузки списка файлов:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка данных из файла с пагинацией
|
||||
*/
|
||||
async function loadFileData(filename: string, page: number = 1): Promise<void> {
|
||||
if (state.isLoadingData) return;
|
||||
|
||||
state.currentFile = filename;
|
||||
state.dataPage = page;
|
||||
|
||||
// Обновляем активный элемент в списке
|
||||
const items = document.querySelectorAll('.log-file-item');
|
||||
Array.from(items).forEach((el) => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
const activeItems = document.querySelectorAll('.log-file-item');
|
||||
for (let i = 0; i < activeItems.length; i++) {
|
||||
const item = activeItems[i] as HTMLElement;
|
||||
if (item.textContent?.includes(filename)) {
|
||||
item.classList.add('active');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
state.isLoadingData = true;
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
if (DOM.dataTableContainer) {
|
||||
DOM.dataTableContainer.innerHTML = '<div class="loading-indicator">⏳ Загрузка данных...</div>';
|
||||
}
|
||||
|
||||
const url = `/api/log/data?file=${encodeURIComponent(filename)}&page=${page}&page_size=${DATA_PAGE_SIZE}`;
|
||||
console.log(`[Logs] Загрузка данных: страница ${page} для ${filename}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: LogDataResponse = await response.json();
|
||||
state.currentData = data;
|
||||
state.dataTotalPages = data.total_pages;
|
||||
|
||||
renderData(data);
|
||||
updateDataPagination(data);
|
||||
|
||||
console.log(`[Logs] Загружено: ${data.samples.length} записей, Страница: ${page}/${data.total_pages}`);
|
||||
} catch (error) {
|
||||
console.error('[Logs] Ошибка загрузки данных:', error);
|
||||
if (DOM.dataTableContainer) {
|
||||
DOM.dataTableContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-message">Нет данных для отображения</div>
|
||||
<div class="empty-hint">Файл пуст или данные ещё не поступили</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (DOM.dataPageInfo) {
|
||||
DOM.dataPageInfo.textContent = 'Нет записей';
|
||||
}
|
||||
if (DOM.dataFirstPageBtn) DOM.dataFirstPageBtn.disabled = true;
|
||||
if (DOM.dataPrevPageBtn) DOM.dataPrevPageBtn.disabled = true;
|
||||
if (DOM.dataNextPageBtn) DOM.dataNextPageBtn.disabled = true;
|
||||
if (DOM.dataLastPageBtn) DOM.dataLastPageBtn.disabled = true;
|
||||
} finally {
|
||||
state.isLoadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка событий с пагинацией
|
||||
*/
|
||||
async function loadEvents(page: number = 1): Promise<void> {
|
||||
if (state.isLoading) return;
|
||||
|
||||
try {
|
||||
state.isLoading = true;
|
||||
state.currentPage = page;
|
||||
|
||||
if (DOM.eventsContent) {
|
||||
DOM.eventsContent.innerHTML = '<div class="loading-indicator">Загрузка событий...</div>';
|
||||
}
|
||||
|
||||
const url = `/api/log/events?page=${page}&page_size=${PAGE_SIZE}&order=newest_first`;
|
||||
console.log(`[Logs] Загрузка событий: страница ${page}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: EventsResponse = await response.json();
|
||||
|
||||
state.totalPages = data.total_pages;
|
||||
state.currentEvents = data.events || [];
|
||||
|
||||
renderEvents(data);
|
||||
updatePagination(data);
|
||||
|
||||
console.log(`[Logs] Загружено: ${data.returned} событий, Страница: ${page}/${data.total_pages}`);
|
||||
} catch (error) {
|
||||
console.error('[Logs] Ошибка загрузки событий:', error);
|
||||
if (DOM.eventsContent) {
|
||||
DOM.eventsContent.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-message">Нет событий</div>
|
||||
<div class="empty-hint">Журнал событий пуст</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ФУНКЦИИ ОТРИСОВКИ
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Отрисовка данных файла
|
||||
*/
|
||||
function renderData(data: LogDataResponse): void {
|
||||
const samples = data.samples || [];
|
||||
|
||||
// Статистика
|
||||
if (DOM.logStats) {
|
||||
const stats = data.stats || { total_points: 0, amplitude_over_0: 0, signal_over_10: 0 };
|
||||
DOM.logStats.innerHTML = `
|
||||
<div class="log-stat-card">
|
||||
<div class="value">${stats.total_points}</div>
|
||||
<div class="label">Всего точек</div>
|
||||
</div>
|
||||
<div class="log-stat-card">
|
||||
<div class="value">${stats.amplitude_over_0}</div>
|
||||
<div class="label">Амплитуда > 0</div>
|
||||
</div>
|
||||
<div class="log-stat-card">
|
||||
<div class="value">${stats.signal_over_10}</div>
|
||||
<div class="label">Сигнал > 10</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// График
|
||||
if (DOM.logChart && data.total > 0) {
|
||||
const history = samples.map((s: LogSample) => s.signal);
|
||||
drawChart(DOM.logChart, history);
|
||||
}
|
||||
|
||||
// Таблица
|
||||
if (samples.length > 0 && DOM.dataTableContainer) {
|
||||
let html = `<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Время</th>
|
||||
<th>Амплитуда</th>
|
||||
<th>Сигнал</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
samples.forEach((s: LogSample, index: number) => {
|
||||
let ampClass = '';
|
||||
if (s.amplitude === 0) ampClass = 'strength-0';
|
||||
else if (s.amplitude === 1) ampClass = 'strength-1';
|
||||
else if (s.amplitude === 2) ampClass = 'strength-2';
|
||||
else if (s.amplitude === 3) ampClass = 'strength-3';
|
||||
|
||||
const num = index + 1;
|
||||
|
||||
html += `<tr>
|
||||
<td style="opacity:0.3;font-size:var(--font-size-xs);">${num}</td>
|
||||
<td>${s.time}</td>
|
||||
<td class="${ampClass}">${s.amplitude}</td>
|
||||
<td>${s.signal}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
DOM.dataTableContainer.innerHTML = html;
|
||||
} else if (DOM.dataTableContainer) {
|
||||
DOM.dataTableContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-message">Нет данных</div>
|
||||
<div class="empty-hint">Выберите файл с данными</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрисовка событий
|
||||
*/
|
||||
function renderEvents(data: EventsResponse): void {
|
||||
if (!DOM.eventsContent) return;
|
||||
|
||||
let html = '';
|
||||
if (data.events.length === 0) {
|
||||
html = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-message">Нет событий</div>
|
||||
<div class="empty-hint">Журнал событий пуст</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
data.events.forEach((e: Event, index: number) => {
|
||||
const num = index + 1;
|
||||
html += `<div>
|
||||
<span class="event-num">#${num}</span>
|
||||
<span class="event-time">[${e.time}]</span>
|
||||
${e.event}
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
DOM.eventsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПАГИНАЦИЯ ДЛЯ ДАННЫХ
|
||||
// ============================================
|
||||
|
||||
function updateDataPagination(data: LogDataResponse): void {
|
||||
const { page, total_pages, has_previous, has_next, start_index, end_index, total } = data;
|
||||
|
||||
if (DOM.dataPageInfo) {
|
||||
if (total === 0) {
|
||||
DOM.dataPageInfo.textContent = 'Нет записей';
|
||||
} else {
|
||||
DOM.dataPageInfo.textContent = `Страница ${page} из ${total_pages} (записи ${start_index}-${end_index} из ${total}) новые сверху`;
|
||||
}
|
||||
}
|
||||
|
||||
if (DOM.dataFirstPageBtn) DOM.dataFirstPageBtn.disabled = !has_previous;
|
||||
if (DOM.dataPrevPageBtn) DOM.dataPrevPageBtn.disabled = !has_previous;
|
||||
if (DOM.dataNextPageBtn) DOM.dataNextPageBtn.disabled = !has_next;
|
||||
if (DOM.dataLastPageBtn) DOM.dataLastPageBtn.disabled = !has_next;
|
||||
|
||||
if (DOM.dataPageInput) {
|
||||
DOM.dataPageInput.value = page.toString();
|
||||
DOM.dataPageInput.max = total_pages.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function goToDataPage(page: number): void {
|
||||
if (page < 1) page = 1;
|
||||
if (page > state.dataTotalPages) page = state.dataTotalPages;
|
||||
if (page === state.dataPage) return;
|
||||
if (!state.currentFile) return;
|
||||
|
||||
void loadFileData(state.currentFile, page);
|
||||
}
|
||||
|
||||
function goToDataFirstPage(): void {
|
||||
goToDataPage(1);
|
||||
}
|
||||
|
||||
function goToDataLastPage(): void {
|
||||
goToDataPage(state.dataTotalPages);
|
||||
}
|
||||
|
||||
function goToDataPreviousPage(): void {
|
||||
goToDataPage(state.dataPage - 1);
|
||||
}
|
||||
|
||||
function goToDataNextPage(): void {
|
||||
goToDataPage(state.dataPage + 1);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ПАГИНАЦИЯ ДЛЯ СОБЫТИЙ
|
||||
// ============================================
|
||||
|
||||
function updatePagination(data: EventsResponse): void {
|
||||
const { page, total_pages, has_previous, has_next, start_index, end_index, total } = data;
|
||||
|
||||
if (DOM.pageInfo) {
|
||||
if (total === 0) {
|
||||
DOM.pageInfo.textContent = 'Нет записей';
|
||||
} else {
|
||||
DOM.pageInfo.textContent = `Страница ${page} из ${total_pages} (записи ${start_index}-${end_index} из ${total}) новые сверху`;
|
||||
}
|
||||
}
|
||||
|
||||
if (DOM.firstPageBtn) DOM.firstPageBtn.disabled = !has_previous;
|
||||
if (DOM.prevPageBtn) DOM.prevPageBtn.disabled = !has_previous;
|
||||
if (DOM.nextPageBtn) DOM.nextPageBtn.disabled = !has_next;
|
||||
if (DOM.lastPageBtn) DOM.lastPageBtn.disabled = !has_next;
|
||||
|
||||
if (DOM.pageInput) {
|
||||
DOM.pageInput.value = page.toString();
|
||||
DOM.pageInput.max = total_pages.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
if (page < 1) page = 1;
|
||||
if (page > state.totalPages) page = state.totalPages;
|
||||
if (page === state.currentPage) return;
|
||||
void loadEvents(page);
|
||||
}
|
||||
|
||||
function goToFirstPage(): void {
|
||||
goToPage(1);
|
||||
}
|
||||
|
||||
function goToLastPage(): void {
|
||||
goToPage(state.totalPages);
|
||||
}
|
||||
|
||||
function goToPreviousPage(): void {
|
||||
goToPage(state.currentPage - 1);
|
||||
}
|
||||
|
||||
function goToNextPage(): void {
|
||||
goToPage(state.currentPage + 1);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ОБНОВЛЕНИЕ ВРЕМЕНИ
|
||||
// ============================================
|
||||
|
||||
async function updateTime(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
const data = await response.json();
|
||||
if (DOM.serverTime) {
|
||||
DOM.serverTime.textContent = data.server_time || new Date().toLocaleTimeString();
|
||||
}
|
||||
} catch (error) {
|
||||
if (DOM.serverTime) {
|
||||
DOM.serverTime.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ИНИЦИАЛИЗАЦИЯ
|
||||
// ============================================
|
||||
|
||||
function initDataPaginationControls(): void {
|
||||
const dataTab = document.getElementById('tab-data');
|
||||
if (!dataTab) return;
|
||||
|
||||
let paginationContainer = document.getElementById('dataPaginationContainer');
|
||||
if (!paginationContainer) {
|
||||
const tableContainer = document.getElementById('dataTableContainer');
|
||||
if (!tableContainer) return;
|
||||
|
||||
paginationContainer = document.createElement('div');
|
||||
paginationContainer.id = 'dataPaginationContainer';
|
||||
paginationContainer.className = 'pagination-container';
|
||||
paginationContainer.innerHTML = `
|
||||
<div class="pagination-controls">
|
||||
<button id="dataFirstPage" class="pagination-btn" title="Первая страница (самые новые)">⏮</button>
|
||||
<button id="dataPrevPage" class="pagination-btn" title="Предыдущая страница (более новые)">◀</button>
|
||||
<button id="dataNextPage" class="pagination-btn" title="Следующая страница (более старые)">▶</button>
|
||||
<button id="dataLastPage" class="pagination-btn" title="Последняя страница (самые старые)">⏭</button>
|
||||
<span class="pagination-goto">
|
||||
<input type="number" id="dataPageInput" min="1" value="1" class="pagination-input">
|
||||
<button id="dataGoToPage" class="pagination-btn pagination-goto-btn">Перейти</button>
|
||||
</span>
|
||||
<span id="dataPageInfo" class="pagination-info">Страница 1 из 1</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
dataTab.insertBefore(paginationContainer, tableContainer);
|
||||
}
|
||||
|
||||
DOM.dataFirstPageBtn = document.getElementById('dataFirstPage') as HTMLButtonElement | null;
|
||||
DOM.dataPrevPageBtn = document.getElementById('dataPrevPage') as HTMLButtonElement | null;
|
||||
DOM.dataNextPageBtn = document.getElementById('dataNextPage') as HTMLButtonElement | null;
|
||||
DOM.dataLastPageBtn = document.getElementById('dataLastPage') as HTMLButtonElement | null;
|
||||
DOM.dataPageInfo = document.getElementById('dataPageInfo') as HTMLElement | null;
|
||||
DOM.dataPageInput = document.getElementById('dataPageInput') as HTMLInputElement | null;
|
||||
DOM.dataGoToPageBtn = document.getElementById('dataGoToPage') as HTMLButtonElement | null;
|
||||
|
||||
if (DOM.dataFirstPageBtn) DOM.dataFirstPageBtn.addEventListener('click', goToDataFirstPage);
|
||||
if (DOM.dataPrevPageBtn) DOM.dataPrevPageBtn.addEventListener('click', goToDataPreviousPage);
|
||||
if (DOM.dataNextPageBtn) DOM.dataNextPageBtn.addEventListener('click', goToDataNextPage);
|
||||
if (DOM.dataLastPageBtn) DOM.dataLastPageBtn.addEventListener('click', goToDataLastPage);
|
||||
|
||||
if (DOM.dataGoToPageBtn) {
|
||||
DOM.dataGoToPageBtn.addEventListener('click', () => {
|
||||
if (DOM.dataPageInput) {
|
||||
const page = parseInt(DOM.dataPageInput.value);
|
||||
if (!isNaN(page) && page >= 1) {
|
||||
goToDataPage(page);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (DOM.dataPageInput) {
|
||||
DOM.dataPageInput.addEventListener('keypress', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && DOM.dataGoToPageBtn) {
|
||||
DOM.dataGoToPageBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initEventsPaginationControls(): void {
|
||||
const eventsTab = document.getElementById('tab-events');
|
||||
if (!eventsTab) return;
|
||||
|
||||
let paginationContainer = document.getElementById('eventsPaginationContainer');
|
||||
if (!paginationContainer) {
|
||||
const eventsContent = document.getElementById('eventsContent');
|
||||
if (!eventsContent) return;
|
||||
|
||||
paginationContainer = document.createElement('div');
|
||||
paginationContainer.id = 'eventsPaginationContainer';
|
||||
paginationContainer.className = 'pagination-container';
|
||||
paginationContainer.innerHTML = `
|
||||
<div class="pagination-controls">
|
||||
<button id="firstPage" class="pagination-btn" title="Первая страница (самые новые)">⏮</button>
|
||||
<button id="prevPage" class="pagination-btn" title="Предыдущая страница (более новые)">◀</button>
|
||||
<button id="nextPage" class="pagination-btn" title="Следующая страница (более старые)">▶</button>
|
||||
<button id="lastPage" class="pagination-btn" title="Последняя страница (самые старые)">⏭</button>
|
||||
<span class="pagination-goto">
|
||||
<input type="number" id="pageInput" min="1" value="1" class="pagination-input">
|
||||
<button id="goToPage" class="pagination-btn pagination-goto-btn">Перейти</button>
|
||||
</span>
|
||||
<span id="pageInfo" class="pagination-info">Страница 1 из 1</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
eventsTab.insertBefore(paginationContainer, eventsContent);
|
||||
}
|
||||
|
||||
DOM.firstPageBtn = document.getElementById('firstPage') as HTMLButtonElement | null;
|
||||
DOM.prevPageBtn = document.getElementById('prevPage') as HTMLButtonElement | null;
|
||||
DOM.nextPageBtn = document.getElementById('nextPage') as HTMLButtonElement | null;
|
||||
DOM.lastPageBtn = document.getElementById('lastPage') as HTMLButtonElement | null;
|
||||
DOM.pageInfo = document.getElementById('pageInfo') as HTMLElement | null;
|
||||
DOM.pageInput = document.getElementById('pageInput') as HTMLInputElement | null;
|
||||
DOM.goToPageBtn = document.getElementById('goToPage') as HTMLButtonElement | null;
|
||||
|
||||
if (DOM.firstPageBtn) DOM.firstPageBtn.addEventListener('click', goToFirstPage);
|
||||
if (DOM.prevPageBtn) DOM.prevPageBtn.addEventListener('click', goToPreviousPage);
|
||||
if (DOM.nextPageBtn) DOM.nextPageBtn.addEventListener('click', goToNextPage);
|
||||
if (DOM.lastPageBtn) DOM.lastPageBtn.addEventListener('click', goToLastPage);
|
||||
|
||||
if (DOM.goToPageBtn) {
|
||||
DOM.goToPageBtn.addEventListener('click', () => {
|
||||
if (DOM.pageInput) {
|
||||
const page = parseInt(DOM.pageInput.value);
|
||||
if (!isNaN(page) && page >= 1) {
|
||||
goToPage(page);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (DOM.pageInput) {
|
||||
DOM.pageInput.addEventListener('keypress', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && DOM.goToPageBtn) {
|
||||
DOM.goToPageBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initTabs(): void {
|
||||
const tabs = document.querySelectorAll('.tab-btn[data-tab]');
|
||||
tabs.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
tabs.forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const tabName = btn.getAttribute('data-tab');
|
||||
const contents = document.querySelectorAll('.tab-content');
|
||||
contents.forEach((el) => el.classList.remove('active'));
|
||||
|
||||
const targetTab = document.getElementById(`tab-${tabName}`);
|
||||
if (targetTab) targetTab.classList.add('active');
|
||||
|
||||
// ===== УПРАВЛЕНИЕ БОКОВОЙ ПАНЕЛЬЮ =====
|
||||
if (tabName === 'data') {
|
||||
// Показываем боковую панель для вкладки "Данные"
|
||||
toggleSidebar(true);
|
||||
// Если данные ещё не загружены, загружаем
|
||||
if (!state.currentData) {
|
||||
const firstFile = document.querySelector('.log-file-item');
|
||||
if (firstFile) {
|
||||
const fileName = firstFile.querySelector('.file-name')?.textContent;
|
||||
if (fileName) {
|
||||
void loadFileData(fileName, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (tabName === 'events') {
|
||||
// Скрываем боковую панель для вкладки "События"
|
||||
toggleSidebar(false);
|
||||
// Загружаем события, если их нет
|
||||
if (state.currentEvents.length === 0) {
|
||||
void loadEvents(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
console.log('[Logs] Инициализация...');
|
||||
|
||||
// По умолчанию показываем боковую панель
|
||||
toggleSidebar(true);
|
||||
|
||||
initDataPaginationControls();
|
||||
initEventsPaginationControls();
|
||||
initTabs();
|
||||
|
||||
await loadFileList();
|
||||
|
||||
await updateTime();
|
||||
setInterval(() => {
|
||||
void updateTime();
|
||||
}, 1000);
|
||||
|
||||
// Проверяем, какая вкладка активна при загрузке
|
||||
const dataTab = document.getElementById('tab-data');
|
||||
const eventsTab = document.getElementById('tab-events');
|
||||
|
||||
if (eventsTab && eventsTab.classList.contains('active')) {
|
||||
toggleSidebar(false);
|
||||
await loadEvents(1);
|
||||
} else {
|
||||
toggleSidebar(true);
|
||||
}
|
||||
|
||||
console.log('[Logs] Инициализация завершена');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ЗАПУСК
|
||||
// ============================================
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
void init();
|
||||
});
|
||||
} else {
|
||||
void init();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// state.js (вся логика состояния)
|
||||
export const state = {
|
||||
maxPoints: 1000,
|
||||
signalHistory: [],
|
||||
smoothValue: 0,
|
||||
};
|
||||
|
||||
// сглаживание
|
||||
export function smooth(target) {
|
||||
state.smoothValue += 0.2 * (target - state.smoothValue);
|
||||
return state.smoothValue;
|
||||
}
|
||||
|
||||
// добавление в историю
|
||||
export function addSignal(value) {
|
||||
state.signalHistory.push(value);
|
||||
|
||||
if (state.signalHistory.length > state.maxPoints) {
|
||||
state.signalHistory.shift();
|
||||
}
|
||||
}
|
||||
223
cmd/server/web/js/state.ts
Normal file
223
cmd/server/web/js/state.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
// state.ts - Peak Holder с фиксированным временем удержания
|
||||
|
||||
interface AppState {
|
||||
maxPoints: number;
|
||||
signalHistory: number[];
|
||||
lastBufferSize: number;
|
||||
|
||||
// Peak Holder для амплитуды
|
||||
peakValue: number; // Текущее удерживаемое пиковое значение амплитуды
|
||||
currentValue: number; // Текущий максимум из буфера
|
||||
peakTimer: number | null; // Таймер для сброса пика амплитуды
|
||||
peakHoldTime: number; // Время удержания пика в миллисекундах
|
||||
peakLocked: boolean; // Заблокирован ли пик (не сбрасывается до таймера)
|
||||
|
||||
// Peak Holder для уровня сигнала
|
||||
peakSignalLevel: number;
|
||||
currentSignalLevel: number;
|
||||
signalPeakTimer: number | null;
|
||||
signalPeakHoldTime: number;
|
||||
signalPeakLocked: boolean;
|
||||
|
||||
hasPeak: boolean;
|
||||
lastMaxSignal: number;
|
||||
}
|
||||
|
||||
type PeakHolderListener = () => void;
|
||||
|
||||
export const state: AppState = {
|
||||
maxPoints: 1000,
|
||||
signalHistory: [],
|
||||
lastBufferSize: 0,
|
||||
|
||||
peakValue: 0,
|
||||
currentValue: 0,
|
||||
peakTimer: null,
|
||||
peakHoldTime: 3000,
|
||||
peakLocked: false,
|
||||
|
||||
peakSignalLevel: 0,
|
||||
currentSignalLevel: 0,
|
||||
signalPeakTimer: null,
|
||||
signalPeakHoldTime: 3000,
|
||||
signalPeakLocked: false,
|
||||
|
||||
hasPeak: false,
|
||||
lastMaxSignal: 0,
|
||||
};
|
||||
|
||||
let peakHolderListener: PeakHolderListener | null = null;
|
||||
|
||||
// ============================================
|
||||
// ПУБЛИЧНЫЕ ФУНКЦИИ
|
||||
// ============================================
|
||||
|
||||
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
|
||||
peakHolderListener = listener;
|
||||
}
|
||||
|
||||
function notifyPeakHolderChange(): void {
|
||||
if (peakHolderListener) {
|
||||
peakHolderListener();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление пикового значения амплитуды
|
||||
* @param currentMax - текущий максимум из буфера (в процентах 0-100)
|
||||
* @returns пиковое значение
|
||||
*/
|
||||
export function updatePeakHolder(currentMax: number): number {
|
||||
state.currentValue = currentMax;
|
||||
state.lastMaxSignal = currentMax;
|
||||
|
||||
// Если пик заблокирован - ничего не меняем, только обновляем currentValue
|
||||
if (state.peakLocked) {
|
||||
return state.peakValue;
|
||||
}
|
||||
|
||||
// Если новое значение БОЛЬШЕ текущего пика - обновляем пик и блокируем
|
||||
if (currentMax > state.peakValue) {
|
||||
state.peakValue = currentMax;
|
||||
state.hasPeak = true;
|
||||
state.peakLocked = true;
|
||||
scheduleAmplitudePeakRelease();
|
||||
notifyPeakHolderChange();
|
||||
return state.peakValue;
|
||||
}
|
||||
|
||||
// Если новое значение МЕНЬШЕ или РАВНО - ничего не делаем
|
||||
return state.peakValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление пикового уровня сигнала
|
||||
* @param currentMaxLevel - текущий максимум уровня из буфера (0-3)
|
||||
* @returns пиковый уровень
|
||||
*/
|
||||
export function updateSignalPeakHolder(currentMaxLevel: number): number {
|
||||
state.currentSignalLevel = currentMaxLevel;
|
||||
|
||||
if (state.signalPeakLocked) {
|
||||
return state.peakSignalLevel;
|
||||
}
|
||||
|
||||
if (currentMaxLevel > state.peakSignalLevel) {
|
||||
state.peakSignalLevel = currentMaxLevel;
|
||||
state.hasPeak = true;
|
||||
state.signalPeakLocked = true;
|
||||
scheduleSignalPeakRelease();
|
||||
notifyPeakHolderChange();
|
||||
return state.peakSignalLevel;
|
||||
}
|
||||
|
||||
return state.peakSignalLevel;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ВНУТРЕННИЕ ФУНКЦИИ
|
||||
// ============================================
|
||||
|
||||
function scheduleAmplitudePeakRelease(): void {
|
||||
if (state.peakTimer !== null) {
|
||||
clearTimeout(state.peakTimer);
|
||||
state.peakTimer = null;
|
||||
}
|
||||
|
||||
state.peakTimer = window.setTimeout(() => {
|
||||
// Сбрасываем пик до текущего значения
|
||||
state.peakValue = state.currentValue;
|
||||
state.peakTimer = null;
|
||||
state.hasPeak = false;
|
||||
state.peakLocked = false;
|
||||
notifyPeakHolderChange();
|
||||
}, state.peakHoldTime);
|
||||
}
|
||||
|
||||
function scheduleSignalPeakRelease(): void {
|
||||
if (state.signalPeakTimer !== null) {
|
||||
clearTimeout(state.signalPeakTimer);
|
||||
state.signalPeakTimer = null;
|
||||
}
|
||||
|
||||
state.signalPeakTimer = window.setTimeout(() => {
|
||||
state.peakSignalLevel = state.currentSignalLevel;
|
||||
state.signalPeakTimer = null;
|
||||
state.hasPeak = false;
|
||||
state.signalPeakLocked = false;
|
||||
notifyPeakHolderChange();
|
||||
}, state.signalPeakHoldTime);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ОСТАЛЬНЫЕ ФУНКЦИИ
|
||||
// ============================================
|
||||
|
||||
export function setSignalPeakHoldTime(ms: number): void {
|
||||
state.signalPeakHoldTime = ms;
|
||||
}
|
||||
|
||||
export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } {
|
||||
return {
|
||||
peak: state.peakSignalLevel,
|
||||
current: state.currentSignalLevel,
|
||||
timerActive: state.signalPeakTimer !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function addSignal(value: number): void {
|
||||
state.signalHistory.push(value);
|
||||
if (state.signalHistory.length > state.maxPoints) {
|
||||
state.signalHistory = state.signalHistory.slice(-state.maxPoints);
|
||||
}
|
||||
}
|
||||
|
||||
export function forceResetSignalPeak(): void {
|
||||
if (state.signalPeakTimer !== null) {
|
||||
clearTimeout(state.signalPeakTimer);
|
||||
state.signalPeakTimer = null;
|
||||
}
|
||||
state.peakSignalLevel = 0;
|
||||
state.currentSignalLevel = 0;
|
||||
state.hasPeak = false;
|
||||
state.lastMaxSignal = 0;
|
||||
state.signalPeakLocked = false;
|
||||
notifyPeakHolderChange();
|
||||
}
|
||||
|
||||
export function forceResetPeak(): void {
|
||||
if (state.peakTimer !== null) {
|
||||
clearTimeout(state.peakTimer);
|
||||
state.peakTimer = null;
|
||||
}
|
||||
state.peakValue = 0;
|
||||
state.currentValue = 0;
|
||||
state.hasPeak = false;
|
||||
state.lastMaxSignal = 0;
|
||||
state.peakLocked = false;
|
||||
forceResetSignalPeak();
|
||||
notifyPeakHolderChange();
|
||||
}
|
||||
|
||||
export function addSignals(values: number[], bufferSize: number): void {
|
||||
if (!Array.isArray(values) || values.length === 0) return;
|
||||
|
||||
const newBytes = bufferSize - state.lastBufferSize;
|
||||
|
||||
if (newBytes > 0 && newBytes <= values.length) {
|
||||
const fresh = values.slice(-newBytes);
|
||||
const decoded = fresh.map(b => b & 0x3F);
|
||||
state.signalHistory.push(...decoded);
|
||||
}
|
||||
|
||||
state.lastBufferSize = bufferSize;
|
||||
|
||||
if (state.signalHistory.length > state.maxPoints) {
|
||||
state.signalHistory = state.signalHistory.slice(-state.maxPoints);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetBufferTracking(): void {
|
||||
state.lastBufferSize = 0;
|
||||
forceResetPeak();
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// ui.js (DOM слой)
|
||||
export let DOM = {};
|
||||
|
||||
export function initDOM() {
|
||||
DOM.status = document.getElementById("status");
|
||||
DOM.meta = document.getElementById("meta");
|
||||
DOM.latest = document.getElementById("latest");
|
||||
DOM.lastByte = document.getElementById("last-byte");
|
||||
DOM.history = document.getElementById("history");
|
||||
DOM.canvas = document.getElementById("signalChart");
|
||||
DOM.bars = document.querySelectorAll(".sig-bar");
|
||||
DOM.barFill = document.getElementById("bar1");
|
||||
|
||||
// Аккордеон
|
||||
DOM.historyHeader = document.getElementById("history-header");
|
||||
DOM.historyBody = document.getElementById("history-body");
|
||||
}
|
||||
|
||||
export function setStatus(v, isError = false) {
|
||||
if (!DOM.status) return;
|
||||
|
||||
DOM.status.textContent = v;
|
||||
|
||||
// Стили для статуса
|
||||
DOM.status.classList.remove("status-ok", "status-error", "status-lost");
|
||||
|
||||
if (isError) {
|
||||
if (v === "Нет связи") {
|
||||
DOM.status.classList.add("status-lost");
|
||||
} else {
|
||||
DOM.status.classList.add("status-error");
|
||||
}
|
||||
} else {
|
||||
DOM.status.classList.add("status-ok");
|
||||
}
|
||||
}
|
||||
|
||||
export function setMeta(v) {
|
||||
if (DOM.meta) DOM.meta.textContent = v;
|
||||
}
|
||||
|
||||
export function setBars(p, noConnection = false) {
|
||||
const el = DOM.barFill;
|
||||
if (!el) return;
|
||||
|
||||
if (noConnection) {
|
||||
// При потере связи - сбрасываем и делаем серым
|
||||
el.style.width = "0%";
|
||||
el.classList.add("no-connection");
|
||||
} else {
|
||||
el.classList.remove("no-connection");
|
||||
el.style.width = Math.max(0, Math.min(100, p)) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
export function setSignal(level, noConnection = false) {
|
||||
const bars = DOM.bars;
|
||||
if (!bars || bars.length < 3) return;
|
||||
|
||||
// Сброс
|
||||
bars.forEach(b => {
|
||||
b.classList.remove("active-1", "active-2", "active-3", "dim", "no-connection");
|
||||
|
||||
if (noConnection) {
|
||||
b.classList.add("no-connection");
|
||||
} else {
|
||||
b.classList.add("dim");
|
||||
}
|
||||
});
|
||||
|
||||
if (noConnection) return;
|
||||
|
||||
// Уровни сигнала
|
||||
if (level >= 1) {
|
||||
bars[0].classList.add("active-1");
|
||||
bars[0].classList.remove("dim");
|
||||
}
|
||||
if (level >= 2) {
|
||||
bars[1].classList.add("active-2");
|
||||
bars[1].classList.remove("dim");
|
||||
}
|
||||
if (level >= 3) {
|
||||
bars[2].classList.add("active-3");
|
||||
bars[2].classList.remove("dim");
|
||||
}
|
||||
}
|
||||
415
cmd/server/web/js/ui.ts
Normal file
415
cmd/server/web/js/ui.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
// ui.ts - Управление UI элементами
|
||||
|
||||
export interface DOMElements {
|
||||
serverTime: HTMLElement | null;
|
||||
status: HTMLElement | null;
|
||||
meta: HTMLElement | null;
|
||||
latest: HTMLElement | null;
|
||||
lastByte: HTMLElement | null;
|
||||
history: HTMLElement | null;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
bars: NodeListOf<HTMLElement>;
|
||||
barFill: HTMLElement | null;
|
||||
bar2: HTMLElement | null;
|
||||
bar3: HTMLElement | null;
|
||||
soundLevel: HTMLElement | null;
|
||||
heightLevel: HTMLElement | null;
|
||||
historyHeader: HTMLElement | null;
|
||||
historyBody: HTMLElement | null;
|
||||
audioIndicator: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const DOM: DOMElements = {
|
||||
serverTime: null,
|
||||
status: null,
|
||||
meta: null,
|
||||
latest: null,
|
||||
lastByte: null,
|
||||
history: null,
|
||||
canvas: null,
|
||||
bars: document.querySelectorAll(".sig-bar"),
|
||||
barFill: null,
|
||||
bar2: null,
|
||||
bar3: null,
|
||||
soundLevel: null,
|
||||
heightLevel: null,
|
||||
historyHeader: null,
|
||||
historyBody: null,
|
||||
audioIndicator: null,
|
||||
};
|
||||
|
||||
export function initDOM(): void {
|
||||
DOM.serverTime = document.getElementById("server-time");
|
||||
DOM.status = document.getElementById("status");
|
||||
DOM.meta = document.getElementById("meta");
|
||||
DOM.latest = document.getElementById("latest");
|
||||
DOM.lastByte = document.getElementById("last-byte");
|
||||
DOM.history = document.getElementById("history");
|
||||
DOM.canvas = document.getElementById("signalChart") as HTMLCanvasElement;
|
||||
DOM.barFill = document.getElementById("bar1");
|
||||
DOM.bar2 = document.getElementById("bar2");
|
||||
DOM.bar3 = document.getElementById("bar3");
|
||||
DOM.soundLevel = document.getElementById("sound-level");
|
||||
DOM.heightLevel = document.getElementById("height-level");
|
||||
DOM.historyHeader = document.getElementById("history-header");
|
||||
DOM.historyBody = document.getElementById("history-body");
|
||||
DOM.bars = document.querySelectorAll(".sig-bar");
|
||||
DOM.audioIndicator = document.getElementById("audio-indicator");
|
||||
}
|
||||
|
||||
export function setServerTime(v: string): void {
|
||||
if (DOM.serverTime) DOM.serverTime.textContent = v;
|
||||
}
|
||||
|
||||
export function setStatus(v: string, isError: boolean = false): void {
|
||||
if (!DOM.status) return;
|
||||
DOM.status.textContent = v;
|
||||
|
||||
DOM.status.classList.remove("status-ok", "status-error", "status-lost");
|
||||
|
||||
if (isError) {
|
||||
if (v === "Нет связи") {
|
||||
DOM.status.classList.add("status-lost");
|
||||
} else {
|
||||
DOM.status.classList.add("status-error");
|
||||
}
|
||||
} else {
|
||||
DOM.status.classList.add("status-ok");
|
||||
}
|
||||
}
|
||||
|
||||
export function setMeta(v: string): void {
|
||||
if (DOM.meta) DOM.meta.textContent = v;
|
||||
}
|
||||
|
||||
// ===== ОСНОВНОЙ ПРОГРЕСС-БАР =====
|
||||
export function setBars(p: number, noConnection: boolean = false): void {
|
||||
const el = DOM.barFill;
|
||||
if (!el) return;
|
||||
|
||||
if (noConnection) {
|
||||
el.style.width = "0%";
|
||||
el.classList.add("no-connection");
|
||||
} else {
|
||||
el.classList.remove("no-connection");
|
||||
const percent = Math.max(0, Math.min(100, p));
|
||||
el.style.width = `${percent}%`;
|
||||
el.style.background = '#e6852d';
|
||||
el.style.backgroundColor = '#e6852d';
|
||||
el.style.backgroundImage = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ИНДИКАТОРЫ УРОВНЯ СИГНАЛА =====
|
||||
export function setSignal(level: number, noConnection: boolean = false): void {
|
||||
const bars = DOM.bars;
|
||||
if (!bars || bars.length < 3) return;
|
||||
|
||||
bars.forEach(bar => {
|
||||
bar.classList.remove("active-1", "active-2", "active-3", "dim", "no-connection");
|
||||
if (noConnection) {
|
||||
bar.classList.add("no-connection");
|
||||
} else {
|
||||
bar.classList.add("dim");
|
||||
}
|
||||
});
|
||||
|
||||
if (noConnection) return;
|
||||
|
||||
if (level >= 1) {
|
||||
bars[0].classList.add("active-1");
|
||||
bars[0].classList.remove("dim");
|
||||
}
|
||||
if (level >= 2) {
|
||||
bars[1].classList.add("active-2");
|
||||
bars[1].classList.remove("dim");
|
||||
}
|
||||
if (level >= 3) {
|
||||
bars[2].classList.add("active-3");
|
||||
bars[2].classList.remove("dim");
|
||||
}
|
||||
}
|
||||
|
||||
export function setAudioIndicator(active: boolean, level: number = 0): void {
|
||||
const el = DOM.audioIndicator;
|
||||
if (!el) return;
|
||||
|
||||
// Проверяем громкость
|
||||
const volume = localStorage.getItem('gport_volume');
|
||||
const currentVolume = volume ? parseInt(volume) : 0;
|
||||
|
||||
if (active && level > 0 && currentVolume > 0) {
|
||||
el.textContent = '🔊';
|
||||
el.style.color = '#00ff88';
|
||||
el.style.opacity = '1';
|
||||
el.style.animation = 'pulse 1s ease-in-out infinite';
|
||||
el.classList.remove('muted');
|
||||
el.classList.add('active');
|
||||
} else {
|
||||
el.textContent = '🔇';
|
||||
el.style.color = '#666';
|
||||
el.style.opacity = '0.5';
|
||||
el.style.animation = 'none';
|
||||
el.classList.add('muted');
|
||||
el.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
export function setSoundLevel(level: number): void {
|
||||
if (DOM.bar2) {
|
||||
const percent = Math.max(0, Math.min(100, level));
|
||||
DOM.bar2.style.width = `${percent}%`;
|
||||
}
|
||||
if (DOM.soundLevel) {
|
||||
DOM.soundLevel.textContent = Math.round(level).toString();
|
||||
}
|
||||
}
|
||||
|
||||
export function setHeightLevel(level: number): void {
|
||||
if (DOM.bar3) {
|
||||
const percent = Math.max(0, Math.min(100, level));
|
||||
DOM.bar3.style.width = `${percent}%`;
|
||||
}
|
||||
if (DOM.heightLevel) {
|
||||
DOM.heightLevel.textContent = Math.round(level).toString();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 8 КАНАЛОВ =====
|
||||
export interface ChannelState {
|
||||
value: number; // Максимальное значение (для прогресс-бара)
|
||||
currentValue: number; // Текущее значение (для отображения)
|
||||
strength: number; // Максимальный уровень
|
||||
currentStrength: number; // Текущий уровень
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export const channels: ChannelState[] = Array(8).fill(null).map(() => ({
|
||||
value: 0,
|
||||
currentValue: 0,
|
||||
strength: 0,
|
||||
currentStrength: 0,
|
||||
expanded: true
|
||||
}));
|
||||
|
||||
export let channelElements: any[] = [];
|
||||
|
||||
export function initChannels() {
|
||||
const container = document.getElementById('channelsContainer');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
channelElements = [];
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'channel-item';
|
||||
item.dataset.channel = String(i);
|
||||
|
||||
if (i === 0) {
|
||||
item.classList.add('channel-active');
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="channel-header" data-channel="${i}">
|
||||
<span class="channel-label">Канал ${i + 1}</span>
|
||||
<span style="display:flex;align-items:center;gap:6px;">
|
||||
<span class="channel-value" id="ch-val-${i}">--</span>
|
||||
<span class="channel-toggle" id="ch-toggle-${i}">▼</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="channel-body" id="ch-body-${i}">
|
||||
<div class="channel-progress">
|
||||
<div class="progress">
|
||||
<div class="fill" id="ch-bar-${i}" style="width:0%; background: #e6852d;"></div>
|
||||
</div>
|
||||
<div class="signal-icon" id="ch-signal-${i}">
|
||||
<div class="sig-bar sig-bar-1"></div>
|
||||
<div class="sig-bar sig-bar-2"></div>
|
||||
<div class="sig-bar sig-bar-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-stats" id="ch-stats-${i}">--</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(item);
|
||||
|
||||
channelElements[i] = {
|
||||
item: item,
|
||||
value: document.getElementById(`ch-val-${i}`),
|
||||
bar: document.getElementById(`ch-bar-${i}`),
|
||||
signal: document.getElementById(`ch-signal-${i}`),
|
||||
stats: document.getElementById(`ch-stats-${i}`),
|
||||
toggle: document.getElementById(`ch-toggle-${i}`),
|
||||
body: document.getElementById(`ch-body-${i}`)
|
||||
};
|
||||
|
||||
const header = item.querySelector('.channel-header');
|
||||
header?.addEventListener('click', () => {
|
||||
toggleChannel(i);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleChannel(index: number) {
|
||||
const ch = channels[index];
|
||||
ch.expanded = !ch.expanded;
|
||||
|
||||
const el = channelElements[index];
|
||||
if (!el) return;
|
||||
|
||||
if (ch.expanded) {
|
||||
el.body.classList.remove('hidden');
|
||||
el.toggle.textContent = '▼';
|
||||
} else {
|
||||
el.body.classList.add('hidden');
|
||||
el.toggle.textContent = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
export function getChannelElement(index: number) {
|
||||
return channelElements[index] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Установка МАКСИМАЛЬНОГО значения для прогресс-бара
|
||||
* (используется для отображения пика)
|
||||
*/
|
||||
export function setChannelValue(index: number, value: number, strength: number) {
|
||||
if (index < 0 || index > 7) return;
|
||||
|
||||
const ch = channels[index];
|
||||
const el = channelElements[index];
|
||||
if (!el) return;
|
||||
|
||||
ch.value = value;
|
||||
ch.strength = strength;
|
||||
|
||||
const maxValue = 63;
|
||||
const percent = (value / maxValue) * 100;
|
||||
|
||||
// Обновляем прогресс-бар
|
||||
if (el.bar) {
|
||||
el.bar.style.width = `${Math.min(100, percent)}%`;
|
||||
el.bar.style.background = '#e6852d';
|
||||
el.bar.style.backgroundColor = '#e6852d';
|
||||
el.bar.style.backgroundImage = 'none';
|
||||
}
|
||||
|
||||
// Обновляем индикатор сигнала (уровень)
|
||||
const bars = el.signal?.querySelectorAll('.sig-bar');
|
||||
if (bars) {
|
||||
bars.forEach((bar: Element, i: number) => {
|
||||
const isActive = i < strength;
|
||||
(bar as HTMLElement).style.opacity = isActive ? '1' : '0.2';
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляем статистику
|
||||
const strengthNames = ['Отсутствует', 'Низкая', 'Средняя', 'Высокая'];
|
||||
if (el.stats) {
|
||||
el.stats.textContent = `${ch.currentValue}/${maxValue} | ${strengthNames[ch.currentStrength] || '---'} (пик: ${value})`;
|
||||
}
|
||||
|
||||
if (el.item) {
|
||||
el.item.classList.remove('no-data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Установка ТЕКУЩЕГО значения для отображения в цифрах
|
||||
* (используется для показа реального состояния)
|
||||
*/
|
||||
export function setChannelCurrentValue(index: number, value: number, strength: number) {
|
||||
if (index < 0 || index > 7) return;
|
||||
|
||||
const ch = channels[index];
|
||||
const el = channelElements[index];
|
||||
if (!el) return;
|
||||
|
||||
ch.currentValue = value;
|
||||
ch.currentStrength = strength;
|
||||
|
||||
// Обновляем числовое значение
|
||||
if (el.value) {
|
||||
el.value.textContent = value.toString();
|
||||
}
|
||||
|
||||
// Обновляем статистику (показываем текущее значение и пик)
|
||||
const maxValue = 63;
|
||||
const strengthNames = ['Отсутствует', 'Низкая', 'Средняя', 'Высокая'];
|
||||
if (el.stats) {
|
||||
el.stats.textContent = `${value}/${maxValue} | ${strengthNames[strength] || '---'} (пик: ${ch.value})`;
|
||||
}
|
||||
|
||||
// Обновляем индикатор сигнала (текущий уровень)
|
||||
const bars = el.signal?.querySelectorAll('.sig-bar');
|
||||
if (bars) {
|
||||
bars.forEach((bar: Element, i: number) => {
|
||||
const isActive = i < strength;
|
||||
// Если текущий уровень меньше максимального - добавляем класс dim
|
||||
if (isActive) {
|
||||
(bar as HTMLElement).classList.remove('dim');
|
||||
(bar as HTMLElement).classList.add('active-' + (i + 1));
|
||||
} else {
|
||||
(bar as HTMLElement).classList.add('dim');
|
||||
(bar as HTMLElement).classList.remove('active-' + (i + 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAllChannels(rawData?: number[]) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
setChannelValue(i, 0, 0);
|
||||
setChannelCurrentValue(i, 0, 0);
|
||||
if (channelElements[i]) {
|
||||
channelElements[i].item.classList.remove('channel-active');
|
||||
}
|
||||
}
|
||||
|
||||
if (rawData && rawData.length > 0) {
|
||||
const lastByte = rawData[rawData.length - 1];
|
||||
const value = lastByte & 0x3F;
|
||||
const strength = (lastByte >> 6) & 0x3;
|
||||
|
||||
setChannelValue(0, value, strength);
|
||||
setChannelCurrentValue(0, value, strength);
|
||||
if (channelElements[0]) {
|
||||
channelElements[0].item.classList.add('channel-active');
|
||||
}
|
||||
|
||||
if (DOM.lastByte) {
|
||||
DOM.lastByte.textContent = value.toString();
|
||||
}
|
||||
} else {
|
||||
if (DOM.lastByte) {
|
||||
DOM.lastByte.textContent = '--';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initChannels();
|
||||
updateAllChannels();
|
||||
|
||||
const expandAll = document.getElementById('expandAllBtn');
|
||||
const collapseAll = document.getElementById('collapseAllBtn');
|
||||
|
||||
if (expandAll) {
|
||||
expandAll.addEventListener('click', () => {
|
||||
channels.forEach((ch, i) => {
|
||||
if (!ch.expanded) toggleChannel(i);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (collapseAll) {
|
||||
collapseAll.addEventListener('click', () => {
|
||||
channels.forEach((ch, i) => {
|
||||
if (ch.expanded) toggleChannel(i);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
74
cmd/server/web/logs.html
Normal file
74
cmd/server/web/logs.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#050805">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>Журнал логов</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
|
||||
<!-- Локальные шрифты -->
|
||||
<link rel="stylesheet" href="/css/fonts.css">
|
||||
|
||||
<!-- ===== СТИЛИ ===== -->
|
||||
<link rel="stylesheet" href="/css/base.css">
|
||||
<link rel="stylesheet" href="/css/header.css">
|
||||
<link rel="stylesheet" href="/css/layout.css">
|
||||
<link rel="stylesheet" href="/css/cards.css">
|
||||
<link rel="stylesheet" href="/css/components.css">
|
||||
<link rel="stylesheet" href="/css/camera.css">
|
||||
<link rel="stylesheet" href="/css/audio.css">
|
||||
<link rel="stylesheet" href="/css/responsive.css">
|
||||
|
||||
<!-- Специфичные стили для страницы логов -->
|
||||
<link rel="stylesheet" href="/css/logs.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header-bar">
|
||||
<h1>ЖУРНАЛ ЛОГОВ</h1>
|
||||
<div class="header-controls">
|
||||
<a href="/" class="header-link">← В режим работы</a>
|
||||
<div id="server-time" class="header-time">--:--:--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs-container">
|
||||
<!-- Боковая панель с файлами -->
|
||||
<div class="logs-sidebar" id="logsSidebar">
|
||||
<h3>Файлы данных</h3>
|
||||
<div id="fileList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<div class="logs-content">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="data">Данные</button>
|
||||
<button class="tab-btn" data-tab="events">События</button>
|
||||
</div>
|
||||
|
||||
<!-- Вкладка: Данные -->
|
||||
<div id="tab-data" class="tab-content active">
|
||||
<div id="logStats" class="log-stats"></div>
|
||||
<div id="chartContainer">
|
||||
<canvas id="logChart" height="200"></canvas>
|
||||
</div>
|
||||
<div id="dataTableContainer"></div>
|
||||
<!-- Пагинация для данных добавляется динамически -->
|
||||
</div>
|
||||
|
||||
<!-- Вкладка: События -->
|
||||
<div id="tab-events" class="tab-content">
|
||||
<div id="eventsContent" class="events-list">Загрузка событий...</div>
|
||||
<!-- Пагинация для событий добавляется динамически -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Подключаем скомпилированный JS -->
|
||||
<script type="module" src="/dist/logs.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
13
cmd/server/web/package.json
Normal file
13
cmd/server/web/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"@types/node": "^20.0.0"
|
||||
}
|
||||
}
|
||||
16
cmd/server/web/tsconfig.json
Normal file
16
cmd/server/web/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./js",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["js/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
9
debian/changelog
vendored
Normal file
9
debian/changelog
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
gpio-monitor-server (1.0.0-1) stable; urgency=medium
|
||||
|
||||
* Initial release
|
||||
* GPIO monitoring with web interface
|
||||
* Systemd service with auto-restart
|
||||
* Log rotation and retention
|
||||
* Support for Raspberry Pi GPIO
|
||||
|
||||
-- НТЦ200 Mon, 23 Jun 2026 12:00:00 +0300
|
||||
14
debian/control
vendored
Normal file
14
debian/control
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
Source: gpio-monitor-server
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: НТЦ200 <ntc200@example.com>
|
||||
Build-Depends: debhelper (>= 11), dh-golang, golang-go, npm
|
||||
Standards-Version: 4.5.0
|
||||
Homepage: https://example.com/gpio-monitor
|
||||
Package: gpio-monitor-server
|
||||
Version: 1.0.0
|
||||
Architecture: arm64
|
||||
Depends: systemd
|
||||
Description: GPIO Monitor Server
|
||||
Сервер для мониторинга GPIO с веб-интерфейсом
|
||||
и системой логирования.
|
||||
29
debian/copyright
vendored
Normal file
29
debian/copyright
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
Format:
|
||||
Upstream-Name: gpio-monitor-server
|
||||
Source:
|
||||
Files: *
|
||||
Copyright: 2024 НТЦ200
|
||||
License: MIT
|
||||
|
||||
Files: debian/*
|
||||
Copyright: 2024 НТЦ200
|
||||
License: MIT
|
||||
|
||||
License: MIT
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
5
debian/dirs
vendored
Normal file
5
debian/dirs
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
usr/bin
|
||||
opt/gpio-monitoring/web
|
||||
etc/gpio-monitoring
|
||||
var/log/gpio-monitoring
|
||||
var/lib/gpio-monitoring
|
||||
15
debian/gpio-monitor-server.conf
vendored
Normal file
15
debian/gpio-monitor-server.conf
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Конфигурация GPIO Monitor Server
|
||||
# Файл: /etc/gpio-monitoring/config
|
||||
|
||||
# Параметры запуска
|
||||
PIPE_PATH=/tmp/gpio_pipe
|
||||
RETENTION_HOURS=72
|
||||
RETENTION_MB=2000
|
||||
RETENTION_INTERVAL=60
|
||||
ROTATION_CHECK_INTERVAL=1
|
||||
BUFFER_SIZE=10240
|
||||
HUMAN_LOG_INTERVAL=5
|
||||
PORT=8080
|
||||
|
||||
# Настройки камеры (если используется)
|
||||
CAMERA_URL=http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg
|
||||
39
debian/gpio-monitor-server.service
vendored
Normal file
39
debian/gpio-monitor-server.service
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
[Unit]
|
||||
Description=GPIO Monitor Server
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=gpio-monitor
|
||||
Group=gpio-monitor
|
||||
WorkingDirectory=/opt/gpio-monitoring
|
||||
|
||||
# Запуск сервера с параметрами
|
||||
ExecStart=/usr/bin/gpio-monitor-server \
|
||||
-pipe /tmp/gpio_pipe \
|
||||
-retention-hours=72 \
|
||||
-retention-mb=2000 \
|
||||
-retention-interval=60 \
|
||||
-rotation-check-interval=1 \
|
||||
-buffer-size=10240 \
|
||||
-human-log-interval=5
|
||||
|
||||
# Перезапуск при падении
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
# Лимиты и безопасность
|
||||
LimitNOFILE=4096
|
||||
NoNewPrivileges=yes
|
||||
|
||||
# Логирование
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=gpio-monitor
|
||||
|
||||
# Настройки для доступа к GPIO
|
||||
AmbientCapabilities=CAP_SYS_RAWIO
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
2
debian/install
vendored
Normal file
2
debian/install
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Бинарник будет установлен в /usr/bin автоматически
|
||||
debian/usr/bin/gpio-logs usr/bin/
|
||||
33
debian/postinst
vendored
Normal file
33
debian/postinst
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# chmod +x debian/postinst
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Создаем директории
|
||||
mkdir -p /var/log/gpio-monitoring
|
||||
mkdir -p /var/lib/gpio-monitoring
|
||||
mkdir -p /opt/gpio-monitoring/web
|
||||
|
||||
# Устанавливаем права
|
||||
chown -R gpio-monitor:gpio-monitor /var/log/gpio-monitoring
|
||||
chown -R gpio-monitor:gpio-monitor /var/lib/gpio-monitoring
|
||||
chown -R gpio-monitor:gpio-monitor /opt/gpio-monitoring
|
||||
|
||||
# Перезагружаем systemd
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
# Включаем сервис, но не запускаем автоматически
|
||||
systemctl enable gpio-monitor-server.service 2>/dev/null || true
|
||||
systemctl start gpio-monitor-server.service
|
||||
|
||||
# Создаем symlink для логов
|
||||
ln -sf /var/log/gpio-monitoring /opt/gpio-monitoring/logs 2>/dev/null || true
|
||||
|
||||
echo "GPIO Monitor Server установлен"
|
||||
echo "Для запуска: sudo systemctl start gpio-monitor-server"
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
19
debian/postrm
vendored
Normal file
19
debian/postrm
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
#chmod +x debian/postrm
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
# Удаляем пользователя только при полной очистке
|
||||
userdel gpio-monitor 2>/dev/null || true
|
||||
groupdel gpio-monitor 2>/dev/null || true
|
||||
|
||||
# Удаляем директории
|
||||
rm -rf /var/log/gpio-monitoring
|
||||
rm -rf /var/lib/gpio-monitoring
|
||||
rm -rf /opt/gpio-monitoring
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
24
debian/preinst
vendored
Normal file
24
debian/preinst
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
#chmod +x debian/preinst
|
||||
|
||||
set -e
|
||||
|
||||
# Создаем пользователя и группу
|
||||
if ! getent group gpio-monitor >/dev/null; then
|
||||
groupadd --system gpio-monitor
|
||||
fi
|
||||
|
||||
if ! getent passwd gpio-monitor >/dev/null; then
|
||||
useradd --system \
|
||||
--gid gpio-monitor \
|
||||
--home /var/lib/gpio-monitoring \
|
||||
--shell /usr/sbin/nologin \
|
||||
--comment "GPIO Monitor Service" \
|
||||
gpio-monitor
|
||||
fi
|
||||
|
||||
# Добавляем пользователя в нужные группы для доступа к GPIO
|
||||
usermod -a -G gpio,dialout,gpio-monitor gpio-monitor 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
15
debian/prerm
vendored
Normal file
15
debian/prerm
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
#chmod +x debian/prerm
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|upgrade|deconfigure)
|
||||
# Останавливаем сервис
|
||||
systemctl stop gpio-monitor-server.service 2>/dev/null || true
|
||||
systemctl disable gpio-monitor-server.service 2>/dev/null || true
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
24
debian/rules
vendored
Normal file
24
debian/rules
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
# chmod +x debian/rules
|
||||
# Удаляем стандартные правила
|
||||
%:
|
||||
dh $@ --buildsystem=golang --with=golang
|
||||
|
||||
# Переопределяем сборку
|
||||
override_dh_auto_build:
|
||||
# Сборка фронтенда
|
||||
cd cmd/server/web && npm install && npm run build
|
||||
# Сборка бекенда
|
||||
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o debian/gpio-monitor-server/usr/bin/gpio-monitor-server ./cmd/server
|
||||
|
||||
# Установка файлов
|
||||
override_dh_install:
|
||||
dh_install
|
||||
# Копируем статические файлы для веба
|
||||
mkdir -p debian/gpio-monitor-server/opt/gpio-monitoring/web
|
||||
cp -r cmd/server/web/dist debian/gpio-monitor-server/opt/gpio-monitoring/web/
|
||||
cp -r cmd/server/web/css debian/gpio-monitor-server/opt/gpio-monitoring/web/
|
||||
cp -r cmd/server/web/fonts debian/gpio-monitor-server/opt/gpio-monitoring/web/
|
||||
cp cmd/server/web/*.html debian/gpio-monitor-server/opt/gpio-monitoring/web/ 2>/dev/null || true
|
||||
cp cmd/server/web/favicon.png debian/gpio-monitor-server/opt/gpio-monitoring/web/ 2>/dev/null || true
|
||||
31
debian/usr/bin/gpio-logs
vendored
Normal file
31
debian/usr/bin/gpio-logs
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Просмотр логов GPIO Monitor
|
||||
|
||||
if [ "$1" = "-f" ]; then
|
||||
sudo journalctl -u gpio-monitor-server -f
|
||||
elif [ "$1" = "-e" ]; then
|
||||
sudo tail -f /var/log/gpio-monitoring/events_human.log
|
||||
elif [ "$1" = "-g" ]; then
|
||||
sudo tail -f /var/log/gpio-monitoring/gpio_human.log
|
||||
elif [ "$1" = "-d" ]; then
|
||||
sudo tail -f /var/log/gpio-monitoring/data/*.bin 2>/dev/null | xxd
|
||||
elif [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||
echo "Использование: gpio-logs [OPTION]"
|
||||
echo " -f Просмотр логов сервиса (journalctl)"
|
||||
echo " -e Просмотр логов событий"
|
||||
echo " -g Просмотр GPIO логов (human-readable)"
|
||||
echo " -d Просмотр бинарных данных (raw)"
|
||||
echo " -h Показать эту справку"
|
||||
else
|
||||
echo "Использование: gpio-logs [OPTION]"
|
||||
echo " -f Просмотр логов сервиса (journalctl)"
|
||||
echo " -e Просмотр логов событий"
|
||||
echo " -g Просмотр GPIO логов (human-readable)"
|
||||
echo " -d Просмотр бинарных данных (raw)"
|
||||
echo " -h Показать эту справку"
|
||||
echo ""
|
||||
echo "Примеры:"
|
||||
echo " gpio-logs -f # следить за логами сервиса"
|
||||
echo " gpio-logs -e # смотреть события"
|
||||
echo " gpio-logs -g # смотреть GPIO логи"
|
||||
fi
|
||||
251
docs/api.md
Normal file
251
docs/api.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Спецификация HTTP API
|
||||
|
||||
Документ описывает HTTP API сервера `gpio-monitor-server`. Источник истины — [cmd/server/main.go](../cmd/server/main.go).
|
||||
|
||||
**Аудитория:** разработчики и интеграторы.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Общие сведения](#общие-сведения)
|
||||
- [GET /api/health](#get-apihealth)
|
||||
- [GET /api/latest](#get-apilatest)
|
||||
- [GET /api/history](#get-apihistory)
|
||||
- [GET /api/stream](#get-apistream)
|
||||
- [GET /api/cam](#get-apicam)
|
||||
- [GET /api/log/files](#get-apilogfiles)
|
||||
- [GET /api/log/data](#get-apilogdata)
|
||||
- [GET /api/log/events](#get-apilogevents)
|
||||
- [Известные особенности](#известные-особенности)
|
||||
|
||||
## Общие сведения
|
||||
|
||||
- **Адрес:** порт задаётся флагом `-port` (по умолчанию `:8080`).
|
||||
- **Метод:** все эндпоинты — только `GET` (плюс `OPTIONS` для CORS preflight).
|
||||
- **Формат ответов:** JSON (`Content-Type: application/json`), кроме `/api/cam` (MJPEG-поток).
|
||||
- **CORS:** на всех эндпоинтах, кроме `/api/cam`, стоит обёртка `cors()`: `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: GET, OPTIONS`. Запрос `OPTIONS` возвращает `200` без тела. `/api/cam` выставляет `Access-Control-Allow-Origin: *` самостоятельно, но `OPTIONS` не обрабатывает.
|
||||
- **Ошибки:** возвращаются как `text/plain` с русскоязычным сообщением и кодом `400`/`404`/`500`/`503`. Неизвестный путь под `/api/` → `404 not found`.
|
||||
- **Статика:** все пути вне `/api/` обслуживаются встроенным веб-дашбордом (`go:embed`, см. [architecture.md](architecture.md)).
|
||||
|
||||
Расшифровка полей `value`/`amplitude`/`signal` — в [data-formats.md](data-formats.md).
|
||||
|
||||
## GET /api/health
|
||||
|
||||
Статус сервера, соединения с pipe и сводная статистика. Основной эндпоинт для опроса дашбордом.
|
||||
|
||||
Пример ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "На связи",
|
||||
"uptime_sec": 12345.67,
|
||||
"last_data_ms": 42,
|
||||
"pipe_alive": true,
|
||||
"has_data": true,
|
||||
"latest": 66,
|
||||
"stats": {
|
||||
"size": 10240,
|
||||
"filled": 10240,
|
||||
"last_write": "2026-07-17T12:34:56.789012345+03:00",
|
||||
"total_bytes": 1234567,
|
||||
"total_bits": 9876536,
|
||||
"bytes_per_sec": 100.5,
|
||||
"bits_per_sec": 804.0
|
||||
},
|
||||
"server_time": "17.07.2026 12:34",
|
||||
"sound_level": 27
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `status` | string | `"На связи"`, если последняя запись в буфер была менее 15 секунд назад, иначе `"Нет связи"` |
|
||||
| `uptime_sec` | float | Время работы сервера в секундах |
|
||||
| `last_data_ms` | int | Миллисекунд с момента последней записи данных |
|
||||
| `pipe_alive` | bool | Была ли запись за последние 3 секунды |
|
||||
| `has_data` | bool | Есть ли хоть один байт в кольцевом буфере |
|
||||
| `latest` | int (0–255) | Последний сырой байт GPIO; `0`, если данных ещё не было |
|
||||
| `stats.size` | int | Ёмкость кольцевого буфера (флаг `-buffer-size`) |
|
||||
| `stats.filled` | int | Сколько ячеек буфера заполнено |
|
||||
| `stats.last_write` | string (RFC 3339) | Время последней записи |
|
||||
| `stats.total_bytes` / `total_bits` | int | Всего принято байт/бит с момента запуска |
|
||||
| `stats.bytes_per_sec` / `bits_per_sec` | float | Текущая скорость приёма |
|
||||
| `server_time` | string | Серверное время в формате `ДД.ММ.ГГГГ ЧЧ:ММ` (два пробела между датой и временем) |
|
||||
| `sound_level` | int (0–100) | Уровень звука с микрофона; `0`, если аудиомонитор не запущен |
|
||||
|
||||
## GET /api/latest
|
||||
|
||||
Последний байт и короткая история.
|
||||
|
||||
Ответ при наличии данных:
|
||||
|
||||
```json
|
||||
{
|
||||
"latest": 66,
|
||||
"history": [64, 65, 66, 66, 65, 64, 66, 67, 66, 66]
|
||||
}
|
||||
```
|
||||
|
||||
- `history` — до 10 последних байт, **от старых к новым** (последний элемент = `latest`).
|
||||
- Если буфер пуст, возвращается `200` с телом `{"error": "no data"}` (не HTTP-ошибка).
|
||||
|
||||
## GET /api/history
|
||||
|
||||
История для графика.
|
||||
|
||||
```json
|
||||
{
|
||||
"bytes": [64, 65, 66, "...", 66]
|
||||
}
|
||||
```
|
||||
|
||||
- `bytes` — массив целых (0–255), до **300** последних байт, от старых к новым. Если данных меньше — вернётся сколько есть.
|
||||
|
||||
## GET /api/stream
|
||||
|
||||
Статус видеопотока камеры.
|
||||
|
||||
```json
|
||||
{
|
||||
"cam": "/api/cam",
|
||||
"available": true,
|
||||
"source": "http://192.168.1.10:1984/api/stream.mjpeg?src=cam_mjpeg"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `cam` | Путь к прокси-эндпоинту MJPEG (всегда `/api/cam`) |
|
||||
| `available` | Доступен ли поток: сервер делает запрос `http://localhost:1984/api/streams?src=cam_mjpeg` и проверяет код 200 |
|
||||
| `source` | Прямой URL потока go2rtc, построенный из хоста запроса (`r.Host`) и порта 1984 |
|
||||
|
||||
⚠️ Проверка `available` захардкожена на `localhost:1984` и **не учитывает флаг `-camera-url`** (main.go:460). См. [tech-debt.md](tech-debt.md).
|
||||
|
||||
## GET /api/cam
|
||||
|
||||
Прокси MJPEG-потока камеры. Сервер запрашивает URL из флага `-camera-url` (по умолчанию `http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg`) и ретранслирует поток клиенту с исходными заголовками, сбрасывая буфер после каждого чтения (chunk 32 КБ).
|
||||
|
||||
Ошибки:
|
||||
|
||||
| Код | Тело | Когда |
|
||||
|---|---|---|
|
||||
| `503` | `камера недоступна` | go2rtc не отвечает |
|
||||
| `500` | `поток не поддерживается` | ResponseWriter не поддерживает Flush |
|
||||
|
||||
## GET /api/log/files
|
||||
|
||||
Список бинарных лог-файлов `gpio-*.bin` из каталога данных (см. [пути хранения](data-formats.md#пути-хранения)).
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "gpio-2026-07-17-12.bin",
|
||||
"path": "/var/log/gpio-monitoring/data/gpio-2026-07-17-12.bin",
|
||||
"size": 34567,
|
||||
"mod_time": "2026-07-17T12:59:59+03:00",
|
||||
"time": "17.07.2026 12:00",
|
||||
"date": "2026-07-17",
|
||||
"hour": 12,
|
||||
"is_active": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- Массив отсортирован по `mod_time`, новые первыми.
|
||||
- `time`/`date`/`hour` вычисляются из имени файла; если имя не соответствует шаблону `gpio-YYYY-MM-DD-HH.bin` — из `mod_time`.
|
||||
- `is_active` в текущей реализации всегда `false` (зарезервировано).
|
||||
- ⚠️ Если файлов нет, возвращается `null`, а не `[]` (сериализация nil-слайса).
|
||||
|
||||
## GET /api/log/data
|
||||
|
||||
Чтение сэмплов из конкретного `.bin`-файла с пагинацией.
|
||||
|
||||
**Параметры запроса:**
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---|---|---|---|
|
||||
| `file` | да | — | Имя файла (например `gpio-2026-07-17-12.bin`); путь очищается через `filepath.Base` — path traversal невозможен |
|
||||
| `page` | нет | 1 | Номер страницы (значения < 1 игнорируются; больше максимума — прижимается к последней) |
|
||||
| `page_size` | нет | 100 | Размер страницы |
|
||||
|
||||
Пример: `GET /api/log/data?file=gpio-2026-07-17-12.bin&page=1&page_size=50`
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "gpio-2026-07-17-12.bin",
|
||||
"total": 3600,
|
||||
"page": 1,
|
||||
"page_size": 50,
|
||||
"total_pages": 72,
|
||||
"has_previous": false,
|
||||
"has_next": true,
|
||||
"start_index": 3551,
|
||||
"end_index": 3600,
|
||||
"samples": [
|
||||
{
|
||||
"ts": 1789034096123456,
|
||||
"time": "17.07.2026 12:34:56",
|
||||
"value": 66,
|
||||
"amplitude": 1,
|
||||
"signal": 2,
|
||||
"strength_name": "Средний"
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"total_points": 3600,
|
||||
"amplitude_over_0": 120,
|
||||
"signal_over_10": 15
|
||||
},
|
||||
"order": "newest_first"
|
||||
}
|
||||
```
|
||||
|
||||
- **Порядок `newest_first`:** страница 1 содержит самые новые сэмплы; внутри страницы сэмплы также идут от новых к старым.
|
||||
- `ts` — метка времени в **микросекундах** Unix; `time` — она же в формате `ДД.ММ.ГГГГ ЧЧ:ММ:СС`.
|
||||
- `value` — сырой байт; `amplitude` — биты 6–7 (0–3); `signal` — биты 0–5 (0–63); `strength_name` — текстовое имя уровня (см. [data-formats.md](data-formats.md#байт-gpio)).
|
||||
- `stats` считается по **всему файлу**, а не по странице: `amplitude_over_0` — сэмплы с амплитудой > 0, `signal_over_10` — с сигналом > 10.
|
||||
- `start_index`/`end_index` — 1-based диапазон в хронологическом порядке файла.
|
||||
- Для пустого файла возвращается `total: 0` и пустой массив `samples`.
|
||||
|
||||
Ошибки: `400 отсутствует параметр file`, `404 файл не найден`, `500` при ошибке чтения.
|
||||
|
||||
⚠️ Файл целиком читается в память до пагинации — учитывайте при больших `.bin`-файлах ([tech-debt.md](tech-debt.md)).
|
||||
|
||||
## GET /api/log/events
|
||||
|
||||
Системные события из `events_human.log` с пагинацией.
|
||||
|
||||
**Параметры:** `page` (по умолчанию 1), `page_size` (по умолчанию 100) — семантика как у `/api/log/data`.
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{ "time": "2026-07-17 12:00:00.001", "event": "ПЛАНОВАЯ_РОТАЦИЯ" },
|
||||
{ "time": "2026-07-17 11:59:12.512", "event": "ОБНАРУЖЕНО_12_ОБЪЕКТОВ_СИЛА_2" }
|
||||
],
|
||||
"total": 254,
|
||||
"page": 1,
|
||||
"page_size": 100,
|
||||
"total_pages": 3,
|
||||
"has_previous": false,
|
||||
"has_next": true,
|
||||
"returned": 100,
|
||||
"start_index": 155,
|
||||
"end_index": 254,
|
||||
"order": "newest_first"
|
||||
}
|
||||
```
|
||||
|
||||
- Парсятся только строки вида `[время] EVENT: имя`; прочие строки учитываются в `total`, но не попадают в `events` (поэтому `returned` может быть меньше размера страницы).
|
||||
- Словарь имён событий — в [data-formats.md](data-formats.md#события-системы).
|
||||
|
||||
Ошибки: `500`, если файл событий недоступен.
|
||||
|
||||
## Известные особенности
|
||||
|
||||
Зафиксированы также в [tech-debt.md](tech-debt.md):
|
||||
|
||||
1. `/api/stream` проверяет доступность камеры по захардкоженному `localhost:1984`, игнорируя `-camera-url`.
|
||||
2. В `main.go` есть неиспользуемая функция `handleCamProxy` (двойник `handleCamProxyWithURL` с захардкоженным URL) — роутер её не вызывает.
|
||||
3. `/api/latest` при пустом буфере возвращает `200` с `{"error": "no data"}`, а не код ошибки.
|
||||
4. `/api/log/files` при отсутствии файлов возвращает `null` вместо пустого массива.
|
||||
5. CORS открыт для всех источников (`*`) — рассчитано на доверенную локальную сеть.
|
||||
150
docs/architecture.md
Normal file
150
docs/architecture.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Архитектура системы
|
||||
|
||||
Описание устройства GPIO Monitor: компоненты, поток данных, конкурентность, топологии развёртывания.
|
||||
|
||||
**Аудитория:** разработчики.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Общая схема](#общая-схема)
|
||||
- [Захват GPIO и доставка данных](#захват-gpio-и-доставка-данных)
|
||||
- [Компоненты Go-сервера](#компоненты-go-сервера)
|
||||
- [Порядок инициализации](#порядок-инициализации)
|
||||
- [Конкурентность](#конкурентность)
|
||||
- [Встраивание фронтенда](#встраивание-фронтенда)
|
||||
- [Топологии развёртывания](#топологии-развёртывания)
|
||||
|
||||
## Общая схема
|
||||
|
||||
```
|
||||
GPIO-шина (8 бит)
|
||||
│ прерывание по стробу WR
|
||||
▼
|
||||
gpio-interrupt (C + WiringPi) ← отдельная программа, вне этого репозитория
|
||||
│ stdout → socket (systemd)
|
||||
▼
|
||||
FIFO pipe /tmp/gpio_pipe ← создаёт systemd (monitor-gpio.socket)
|
||||
│ сырые байты
|
||||
▼
|
||||
┌──────────────────────── Go-сервер (gpio-monitor-server) ────────────────────────┐
|
||||
│ PipeReader (internal/pipe) │
|
||||
│ ├─► RingBuffer (internal/adapter) — RAM, для UI │
|
||||
│ ├─► RotatingLogger → DataLogger — .bin-архив на диске │
|
||||
│ ├─► HumanLogger — gpio_human.log │
|
||||
│ ├─► EventLogger (алерты count > 10) — events.log / events_human.log │
|
||||
│ └─► Monitor (watchdog тишины) │
|
||||
│ │
|
||||
│ Retention (internal/logger) — фоновая очистка .bin │
|
||||
│ audio.Monitor (internal/audio) — уровень звука через arecord │
|
||||
│ │
|
||||
│ HTTP :8080 │
|
||||
│ ├─ /api/* — JSON API (см. api.md) │
|
||||
│ ├─ /api/cam — прокси MJPEG с go2rtc (:1984) │
|
||||
│ └─ /* — встроенный веб-дашборд (go:embed) │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ HTTP-опрос (~1 раз/с)
|
||||
Браузер (dashboard.html / logs.html, TypeScript — см. frontend.md)
|
||||
```
|
||||
|
||||
Форматы данных на каждом участке — в [data-formats.md](data-formats.md), API — в [api.md](api.md).
|
||||
|
||||
## Захват GPIO и доставка данных
|
||||
|
||||
Захват выполняет C-программа `gpio-interrupt` (WiringPi), которая по прерыванию строба читает биты шины и пишет байты в stdout. **Её исходников в этом репозитории нет** — она живёт на устройстве в `/home/user/WiringPi/examples` (см. [tech-debt.md](tech-debt.md)).
|
||||
|
||||
Доставка построена на **systemd socket-activation** ([scripts/monitor-gpio.socket](../scripts/monitor-gpio.socket), [scripts/monitor-gpio.service](../scripts/monitor-gpio.service)):
|
||||
|
||||
1. `monitor-gpio.socket` создаёт FIFO `/tmp/gpio_pipe` (`ListenFIFO`, права 0666, `RemoveOnStop=yes`);
|
||||
2. `monitor-gpio.service` запускает `gpio-interrupt` со `StandardOutput=socket` — stdout программы направляется прямо в FIFO;
|
||||
3. Go-сервер открывает FIFO на чтение (флаг `-pipe`).
|
||||
|
||||
Такая схема развязывает жизненные циклы: капчер и сервер можно перезапускать независимо, pipe создаёт и убирает systemd.
|
||||
|
||||
## Компоненты Go-сервера
|
||||
|
||||
### PipeReader — [internal/pipe/reader.go](../internal/pipe/reader.go)
|
||||
|
||||
Единственный «производитель» данных. В отдельной горутине: следит за существованием FIFO (проверка каждые 2 с), открывает его, читает блоками до 4096 байт и раздаёт каждый байт потребителям (см. схему). Ведёт машину состояний `unknown → found / not_found / error / disconnected` и пишет события `PIPE_*` **только при смене состояния** — защита от спама в лог при флапающем соединении. При обрыве чтения переподключается через 1 с.
|
||||
|
||||
Дополнительно дедуплицирует human-лог (пишет при изменении `count`/`strength` или раз в `-human-log-interval` с) и алерты (`-alert-cooldown` на одинаковый `count`).
|
||||
|
||||
### RingBuffer — [internal/adapter/buffer.go](../internal/adapter/buffer.go)
|
||||
|
||||
Кольцевой буфер последних N байт (флаг `-buffer-size`, по умолчанию 10240) — источник данных для `/api/latest`, `/api/history`, `/api/health`. Потокобезопасен: данные под `sync.RWMutex`, счётчики (`lastWrite`, `totalBytes`) — атомики.
|
||||
|
||||
Скорость приёма считается **двумя механизмами**, оба пишут в одни и те же атомики `currentBPS`/`currentBPSBits`: периодический пересчёт по дельте счётчика (не чаще раза в 250 мс) и скользящее окно 1 с по временным меткам последних байт. Второй перетирает первый; есть и публичный `GetWindowSpeed()`, который API не использует. Это избыточность — кандидат на упрощение ([tech-debt.md](tech-debt.md)).
|
||||
|
||||
### Логгеры — [internal/logger/](../internal/logger/)
|
||||
|
||||
- **RotatingLogger** (`rotation.go`) — фасад над DataLogger: держит файл текущего часа, по таймеру (`-rotation-check-interval`) проверяет смену часа и переоткрывает файл.
|
||||
- **DataLogger** (`data_logger.go`) — бинарная запись сэмплов (9 байт) с буфером 64 КБ, flush раз в 1 с или при заполнении, `fsync` после каждого сброса.
|
||||
- **HumanLogger** (`human_logger.go`) — построчная запись в `gpio_human.log` с немедленным `Sync`.
|
||||
- **EventLogger** (`event_logger.go`) — системные события, двойная запись: JSON (`events.log`) + текст (`events_human.log`).
|
||||
- **Monitor** (`monitor.go`) — watchdog: PipeReader отмечает каждую запись (`RecordWrite`), фоновый цикл раз в `-watchdog-interval` минут сравнивает тишину с порогами и пишет `ТИШИНА_5МИН`/`ТИШИНА_10МИН`.
|
||||
- **Retention** (`retention.go`) — фоновая FIFO-очистка `.bin`-файлов по возрасту и суммарному размеру, минимум 2 файла всегда сохраняются; устойчива к скачку системных часов (подробности в [data-formats.md](data-formats.md#retention-очистка)).
|
||||
|
||||
### Аудиомонитор — [internal/audio/monitor.go](../internal/audio/monitor.go)
|
||||
|
||||
Запускает `arecord` (ALSA: raw, 1 канал, 16 кГц, S16_LE), автоматически выбирая USB-аудиоустройство по выводу `arecord -l` (fallback — первое устройство захвата, затем `default`). Читает PCM блоками 3200 байт (100 мс), считает RMS и публикует уровень 0–100 (`RMS/32768 × 100 × 4`, с отсечкой). При падении `arecord` перезапускает захват через 5 с. Уровень отдаётся в `/api/health` полем `sound_level`. Недоступность микрофона не мешает запуску сервера.
|
||||
|
||||
### HTTP-слой — [cmd/server/main.go](../cmd/server/main.go)
|
||||
|
||||
Стандартный `net/http` без внешних роутеров: `/api/` обслуживает switch по пути (8 эндпоинтов, все под CORS-обёрткой, кроме `/api/cam`), остальное — `http.FileServer` поверх встроенных веб-файлов. Прокси камеры ретранслирует MJPEG-поток из go2rtc с flush после каждого чанка 32 КБ.
|
||||
|
||||
## Порядок инициализации
|
||||
|
||||
Последовательность в `main()` (важна: каждый следующий компонент получает уже готовые предыдущие):
|
||||
|
||||
1. Разбор CLI-флагов → `logger.Config`;
|
||||
2. **EventLogger** (без него сервер не стартует) → событие `СЕРВЕР_ЗАПУЩЕН`;
|
||||
3. **HumanLogger**;
|
||||
4. **Monitor** (watchdog) — сразу запускает фоновый цикл;
|
||||
5. **RotatingLogger** — открывает файл текущего часа, запускает цикл ротации;
|
||||
6. **Retention** — если `-retention-hours > 0` или `-retention-mb > 0`; первая очистка через 1 мин;
|
||||
7. **RingBuffer**;
|
||||
8. **audio.Monitor** — ошибка запуска не фатальна;
|
||||
9. **PipeReader.Start** — горутина чтения FIFO;
|
||||
10. Регистрация HTTP-хендлеров и `ListenAndServe` (блокируется навсегда).
|
||||
|
||||
Ошибка инициализации любого логгера (шаги 2–5) — `log.Fatal`, сервер не запускается.
|
||||
|
||||
## Конкурентность
|
||||
|
||||
| Горутина | Кто запускает | Что делает |
|
||||
|---|---|---|
|
||||
| Чтение FIFO | `PipeReader.Start` | Цикл открытия/чтения пайпа, раздача байт |
|
||||
| Flush-цикл DataLogger | `NewDataLogger` (пересоздаётся при ротации) | Сброс буфера раз в 1 с |
|
||||
| Цикл ротации | `NewRotatingLogger` | Проверка смены часа |
|
||||
| Watchdog | `NewMonitor` | Проверка тишины |
|
||||
| Цикл retention | `Retention.StartWithInterval` | Периодическая очистка + разовая через 1 мин |
|
||||
| Чтение PCM | `audio.Monitor.startCapture` | RMS-расчёт уровня звука |
|
||||
| `cmd.Wait` arecord | `startCapture` | Сбор зомби-процесса |
|
||||
| HTTP-хендлеры | `net/http` | По горутине на запрос |
|
||||
|
||||
Синхронизация: у каждого компонента свой мьютекс (`RingBuffer.mu`+`speedMu`+`recentMu`, `DataLogger.mu`, `RotatingLogger.mu`, `EventLogger.mu`, `HumanLogger.mu`, `Monitor.mu`, `audio.Monitor.mu`); межкомпонентных блокировок нет — данные передаются вызовами методов, владение файлами не разделяется. Завершение — через `context` (PipeReader, audio) и каналы (`DataLogger.closeCh`, `Retention.stopCh`).
|
||||
|
||||
## Встраивание фронтенда
|
||||
|
||||
```go
|
||||
//go:embed web/dist web/css web/fonts web/*.html web/*.png
|
||||
var webFiles embed.FS
|
||||
```
|
||||
|
||||
Скомпилированный дашборд вшивается в бинарник на этапе `go build` — сервер разворачивается одним файлом. Следствие: **`go build` падает, если `web/dist` не существует**, поэтому фронтенд всегда собирается первым (`make build` = `build-frontend` + `build-backend`). Подробнее — [development.md](development.md) и [frontend.md](frontend.md).
|
||||
|
||||
## Топологии развёртывания
|
||||
|
||||
Режим определяется наличием каталога `/opt/gpio-monitoring` ([internal/logger/paths.go](../internal/logger/paths.go)):
|
||||
|
||||
| | Пакетный режим (deb) | Режим разработки |
|
||||
|---|---|---|
|
||||
| Бинарник | `/usr/bin/gpio-monitor-server` | `./build/gpio-monitor-server` или `go run` |
|
||||
| Запуск | systemd `gpio-monitor-server.service`, пользователь `gpio-monitor` | вручную |
|
||||
| Логи | `/var/log/gpio-monitoring` | `~/.local/share/gpio-monitoring/logs` |
|
||||
| Веб-файлы | вшиты в бинарник (копия в `/opt/gpio-monitoring/web`) | вшиты в бинарник |
|
||||
| Источник данных | реальный `gpio-interrupt` через systemd socket | эмулятор `scripts/emulator.py` + `mkfifo` |
|
||||
|
||||
Внешние сервисы в обоих режимах: **go2rtc** (порт 1984, конфиг [scripts/go2rtc.yaml](../scripts/go2rtc.yaml)) для MJPEG-камеры и **ALSA/arecord** для микрофона. Установка и настройка — в [operations.md](operations.md).
|
||||
|
||||
Подпроект [SDR/](../SDR/) (OFDM-радиолинк на PlutoSDR) — автономный C-проект со своей сборкой и [README](../SDR/README.md); с Go-сервером не связан: канал HackRF/PlutoSDR используется как альтернативный транспорт байтов GPIO-шины (см. [scripts/emulatohackrf.sh](../scripts/emulatohackrf.sh)).
|
||||
169
docs/data-formats.md
Normal file
169
docs/data-formats.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Форматы данных и логов
|
||||
|
||||
Документ описывает все форматы данных системы: байт GPIO, протокол FIFO-пайпа, бинарные и текстовые логи, пути хранения и правила очистки.
|
||||
|
||||
**Аудитория:** разработчики и интеграторы.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Байт GPIO](#байт-gpio)
|
||||
- [Протокол FIFO-пайпа](#протокол-fifo-пайпа)
|
||||
- [Бинарные логи (.bin)](#бинарные-логи-bin)
|
||||
- [Человекочитаемый GPIO-лог](#человекочитаемый-gpio-лог)
|
||||
- [События системы](#события-системы)
|
||||
- [Пути хранения](#пути-хранения)
|
||||
- [Ротация](#ротация)
|
||||
- [Retention (очистка)](#retention-очистка)
|
||||
|
||||
## Байт GPIO
|
||||
|
||||
Единица данных системы — один байт, снятый с 8-битной GPIO-шины. Разбор — в [internal/logger/parser.go](../internal/logger/parser.go) (`ParseGPIO`):
|
||||
|
||||
```
|
||||
бит: 7 6 | 5 4 3 2 1 0
|
||||
└─┬─┘ └────┬─────┘
|
||||
strength count
|
||||
(0–3) (0–63)
|
||||
```
|
||||
|
||||
| Поле | Биты | Диапазон | Смысл |
|
||||
|---|---|---|---|
|
||||
| `count` (в API — `signal`) | 0–5 | 0–63 | Количество обнаружений |
|
||||
| `strength` (в API — `amplitude`) | 6–7 | 0–3 | Сила сигнала |
|
||||
|
||||
Текстовые имена уровней силы (`GetStrengthName`):
|
||||
|
||||
| Значение | Имя |
|
||||
|---|---|
|
||||
| 0 | Слабый |
|
||||
| 1 | Средний |
|
||||
| 2 | Сильный |
|
||||
| 3 | Максимальный |
|
||||
|
||||
**Порог тревоги:** при `count > 10` (`IsHighCount`) генерируется событие `ОБНАРУЖЕНО_<count>_ОБЪЕКТОВ_СИЛА_<strength>` и строка «ВНИМАНИЕ» в human-логе (анти-спам: не чаще одного алерта на одинаковый `count` за `-alert-cooldown` секунд).
|
||||
|
||||
Пример: байт `0x8C` = `1000 1100` → strength = 2 («Сильный»), count = 12 → сработает алерт.
|
||||
|
||||
## Протокол FIFO-пайпа
|
||||
|
||||
Связь C-программы захвата с Go-сервером — именованный канал (по умолчанию `/tmp/gpio_pipe`, флаг `-pipe`).
|
||||
|
||||
- Поток **сырых байт без кадрирования**: каждый байт — одно измерение в формате [байта GPIO](#байт-gpio). Никаких заголовков, разделителей и контрольных сумм.
|
||||
- Сервер читает блоками до 4096 байт ([internal/pipe/reader.go](../internal/pipe/reader.go)).
|
||||
- Метка времени присваивается **на стороне сервера** в момент чтения (`time.Now().UnixMicro()`).
|
||||
- При отсутствии/обрыве пайпа сервер переподключается сам: проверка существования каждые 2 с, повторное открытие через 1 с; смены состояния фиксируются событиями `PIPE_*` (см. [словарь событий](#события-системы)).
|
||||
|
||||
## Бинарные логи (.bin)
|
||||
|
||||
Основной архив данных. Запись — [internal/logger/data_logger.go](../internal/logger/data_logger.go), чтение — `HandleLogData` в [cmd/server/main.go](../cmd/server/main.go).
|
||||
|
||||
**Формат файла:** конкатенация записей по 9 байт, без заголовка и футера:
|
||||
|
||||
```
|
||||
┌────────────────────────────────┬───────────┐
|
||||
│ timestamp: uint64 LE, 8 байт │ value: 1б │
|
||||
└────────────────────────────────┴───────────┘
|
||||
```
|
||||
|
||||
- `timestamp` — микросекунды Unix (`UnixMicro`), little-endian;
|
||||
- `value` — сырой [байт GPIO](#байт-gpio).
|
||||
|
||||
**Буферизация записи:** сэмплы копятся в буфере 64 КБ и сбрасываются на диск раз в 1 секунду либо при заполнении буфера; после каждого сброса вызывается `fsync` (durability при отключении питания).
|
||||
|
||||
**Именование:** `gpio-YYYY-MM-DD-HH.bin`, время **локальное**, один файл на час (см. [Ротация](#ротация)). Пример: `gpio-2026-07-17-12.bin` — данные за 12:00–12:59 17 июля 2026.
|
||||
|
||||
**Чтение на другой платформе:** каждая запись — `<Q` + `B` в терминах Python `struct`:
|
||||
|
||||
```python
|
||||
import struct
|
||||
with open("gpio-2026-07-17-12.bin", "rb") as f:
|
||||
while chunk := f.read(9):
|
||||
if len(chunk) < 9:
|
||||
break
|
||||
ts_us, value = struct.unpack("<QB", chunk)
|
||||
strength, count = (value >> 6) & 0x3, value & 0x3F
|
||||
```
|
||||
|
||||
## Человекочитаемый GPIO-лог
|
||||
|
||||
Файл `gpio_human.log` (см. [пути](#пути-хранения)), пишет [internal/pipe/reader.go](../internal/pipe/reader.go) через `HumanLogger`.
|
||||
|
||||
Обычная строка (пишется при изменении `count`/`strength` либо раз в `-human-log-interval` секунд):
|
||||
|
||||
```
|
||||
[2026-07-17 12:34:56.789] Обнаружение: 12 объектов | Сила: Сильный (уровень 2) | Сырое: 0x8C (140)
|
||||
```
|
||||
|
||||
Строка тревоги (при `count > 10`, с учётом анти-спама):
|
||||
|
||||
```
|
||||
[2026-07-17 12:34:56.789] ВНИМАНИЕ: Обнаружено превышение! 12 объектов | Сила: Сильный (уровень 2)
|
||||
```
|
||||
|
||||
## События системы
|
||||
|
||||
События пишутся [internal/logger/event_logger.go](../internal/logger/event_logger.go) **сразу в два файла**:
|
||||
|
||||
1. `events.log` — JSON-строки (одна на событие):
|
||||
|
||||
```json
|
||||
{"ts": 1789034096, "time": "2026-07-17 12:34:56", "event": "ПЛАНОВАЯ_РОТАЦИЯ"}
|
||||
```
|
||||
|
||||
2. `events_human.log` — текст (этот файл читает `/api/log/events`):
|
||||
|
||||
```
|
||||
[2026-07-17 12:34:56.789] EVENT: ПЛАНОВАЯ_РОТАЦИЯ
|
||||
```
|
||||
|
||||
**Словарь событий** (по вызовам `Event(...)` в коде):
|
||||
|
||||
| Событие | Источник | Когда |
|
||||
|---|---|---|
|
||||
| `СЕРВЕР_ЗАПУЩЕН` | main.go | Старт сервера |
|
||||
| `HUMAN_ЛОГЕР_ГОТОВ` | main.go | Инициализация HumanLogger |
|
||||
| `DATA_ЛОГЕР_ГОТОВ` | main.go | Инициализация RotatingLogger |
|
||||
| `ОШИБКА_ИНИЦИАЛИЗАЦИИ_DATA_ЛОГЕРА` | main.go | Сбой инициализации (сервер завершается) |
|
||||
| `RETENTION_ГОТОВ` | main.go | Запуск очистки логов |
|
||||
| `ПЛАНОВАЯ_РОТАЦИЯ` | rotation.go | Открыт новый почасовой файл |
|
||||
| `ПЛАНОВАЯ_РОТАЦИЯ_НЕУДАЧНА` | rotation.go | Не удалось открыть новый файл |
|
||||
| `PIPE_НЕ_НАЙДЕН` | reader.go | FIFO-файл отсутствует |
|
||||
| `ОШИБКА_ОТКРЫТИЯ_PIPE` | reader.go | FIFO существует, но не открывается |
|
||||
| `PIPE_ПОДКЛЮЧЕН` | reader.go | Пайп открыт / связь восстановлена |
|
||||
| `PIPE_ОТКЛЮЧЕН` | reader.go | Ошибка чтения из пайпа |
|
||||
| `ТИШИНА_5МИН` | monitor.go | Нет данных дольше порога `-silence-5min` |
|
||||
| `ТИШИНА_10МИН` | monitor.go | Нет данных дольше порога `-silence-10min` |
|
||||
| `ОБНАРУЖЕНО_<N>_ОБЪЕКТОВ_СИЛА_<M>` | reader.go | Алерт превышения (`count > 10`) |
|
||||
| `ОЧИСТКА_ЛОГОВ_УДАЛЕНО_<N>_ФАЙЛОВ_<M>_MB` | retention.go | Retention удалил файлы |
|
||||
| `ОШИБКА_ПОЛУЧЕНИЯ_ДИРЕКТОРИИ` | retention.go | Retention не смог получить каталог данных |
|
||||
|
||||
События смены состояния пайпа пишутся **только при изменении** состояния (дедупликация в `PipeReader`), тишина — при каждой проверке watchdog (раз в `-watchdog-interval` минут), пока тишина длится.
|
||||
|
||||
## Пути хранения
|
||||
|
||||
Все пути вычисляет [internal/logger/paths.go](../internal/logger/paths.go). Режим определяется по существованию каталога `/opt/gpio-monitoring`: есть — «пакетный» режим (deb-установка), нет — режим разработки (XDG).
|
||||
|
||||
| Что | Пакетный режим | Режим разработки |
|
||||
|---|---|---|
|
||||
| Данные приложения | `/var/lib/gpio-monitoring` | `~/.local/share/gpio-monitoring` |
|
||||
| Каталог логов | `/var/log/gpio-monitoring` | `~/.local/share/gpio-monitoring/logs` |
|
||||
| Бинарные `.bin` | `/var/log/gpio-monitoring/data/` | `~/.local/share/gpio-monitoring/logs/data/` |
|
||||
| События (JSON) | `.../events.log` | `.../logs/events.log` |
|
||||
| События (текст) | `.../events_human.log` | `.../logs/events_human.log` |
|
||||
| GPIO human-лог | `.../gpio_human.log` | `.../logs/gpio_human.log` |
|
||||
|
||||
## Ротация
|
||||
|
||||
[internal/logger/rotation.go](../internal/logger/rotation.go): `RotatingLogger` держит текущий `DataLogger` и раз в `-rotation-check-interval` минут (по умолчанию 1) сверяет текущий час с часом открытого файла. При смене часа старый файл закрывается (с финальным flush) и открывается новый `gpio-YYYY-MM-DD-HH.bin`; пишется событие `ПЛАНОВАЯ_РОТАЦИЯ`.
|
||||
|
||||
## Retention (очистка)
|
||||
|
||||
[internal/logger/retention.go](../internal/logger/retention.go): фоновая FIFO-очистка каталога `data/`. Проверка раз в `-retention-interval` минут (по умолчанию 15), первая — через 1 минуту после старта.
|
||||
|
||||
Алгоритм `Cleanup()`:
|
||||
|
||||
1. **По возрасту** (`-retention-hours`, 0 = отключено): файлы старше порога удаляются с самого старого края. Файл считается устаревшим, только если он старше порога **и по времени из имени, и по ModTime** — защита от скачка системных часов после NTP-синхронизации на устройстве без RTC.
|
||||
2. **По размеру** (`-retention-mb`, 0 = отключено): пока суммарный размер `.bin`-файлов превышает лимит, удаляется самый старый файл.
|
||||
3. В любом случае сохраняются минимум **2 самых свежих файла** (`minKeepFiles`) — защита от полного стирания каталога.
|
||||
|
||||
Если имя файла не соответствует шаблону `gpio-YYYY-MM-DD-HH.bin`, вместо времени из имени используется ModTime. Поведение закреплено тестами [internal/logger/retention_test.go](../internal/logger/retention_test.go).
|
||||
115
docs/development.md
Normal file
115
docs/development.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Руководство разработчика
|
||||
|
||||
Сборка, запуск без железа, тесты и соглашения проекта.
|
||||
|
||||
**Аудитория:** разработчики.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Окружение](#окружение)
|
||||
- [Порядок сборки](#порядок-сборки)
|
||||
- [Цели Makefile](#цели-makefile)
|
||||
- [Сборка deb-пакета](#сборка-deb-пакета)
|
||||
- [Запуск без железа (эмуляция)](#запуск-без-железа-эмуляция)
|
||||
- [Тесты](#тесты)
|
||||
- [Соглашения](#соглашения)
|
||||
|
||||
## Окружение
|
||||
|
||||
- **Go 1.21+** — модуль `gpio-monitor`, **внешних Go-зависимостей нет** (только стандартная библиотека, см. [go.mod](../go.mod));
|
||||
- **Node.js 20+ и npm** — только для компиляции TypeScript (единственная dev-зависимость — `typescript`);
|
||||
- `make`, `git`; для deb-пакета — `dpkg-deb`; опционально `golangci-lint` для `make lint`.
|
||||
|
||||
Подготовка после клонирования:
|
||||
|
||||
```bash
|
||||
make init # проверит Node.js, скачает Go-модули и npm-пакеты
|
||||
```
|
||||
|
||||
## Порядок сборки
|
||||
|
||||
**Критичный нюанс:** сервер встраивает веб-файлы через `go:embed web/dist ...` ([cmd/server/main.go:28](../cmd/server/main.go)). Каталог `dist/` генерируется компилятором TypeScript и в git не хранится, поэтому **на чистом checkout `go build` упадёт с ошибкой embed**. Всегда собирайте фронтенд первым:
|
||||
|
||||
```bash
|
||||
make build # = build-frontend (tsc) + build-backend (go build)
|
||||
```
|
||||
|
||||
По умолчанию бинарник собирается кросс-компиляцией под **linux/arm64** (Raspberry Pi) в `build/gpio-monitor-server`. Для локальной платформы используйте `make build-amd64` / `build-mac` / `build-windows` или `make dev`.
|
||||
|
||||
Есть также исторический скрипт [build.sh](../build.sh) — делает то же, что `make build`, но с захардкоженным путём проекта `~/temp/golang`; предпочитайте Makefile.
|
||||
|
||||
## Цели Makefile
|
||||
|
||||
Справка встроена: `make help`. Основные цели ([Makefile](../Makefile)):
|
||||
|
||||
| Цель | Что делает |
|
||||
|---|---|
|
||||
| `init` | Проверка Node.js/npm + установка всех зависимостей |
|
||||
| `build` | Полная сборка: фронтенд + бекенд (GOOS/GOARCH переопределяемы) |
|
||||
| `build-frontend` | `npm install` (при отсутствии node_modules) + `npm run build` (tsc) |
|
||||
| `build-backend` | `go build -ldflags="-s -w"` в `build/` |
|
||||
| `run` | Сборка + запуск бинарника |
|
||||
| `dev` | `go run ./cmd/server -port :8080` без оптимизаций (dist уже должен существовать) |
|
||||
| `test` | `go test -v ./...` |
|
||||
| `fmt` | `go fmt ./...` |
|
||||
| `lint` | `golangci-lint run ./...` (если установлен) |
|
||||
| `clean` | Удаляет `build/`, `web/dist`, `web/node_modules`, временные deb-файлы |
|
||||
| `build-arm64` / `build-amd64` / `build-mac` / `build-windows` / `build-all` | Кросс-сборки |
|
||||
| `install` / `uninstall` | Копирование бинарника в `/usr/local/bin` |
|
||||
| `deb` / `deb-info` / `deb-contents` / `deb-clean` / `version` | Работа с deb-пакетом (ниже) |
|
||||
|
||||
> Цели `docker-build`/`docker-run` объявлены, но Dockerfile в репозитории отсутствует — они нерабочие ([tech-debt.md](tech-debt.md#сборка-и-инфраструктура)).
|
||||
|
||||
## Сборка deb-пакета
|
||||
|
||||
```bash
|
||||
make deb # от обычного пользователя, без sudo
|
||||
make version # показать вычисленную версию и имя файла
|
||||
make deb-info # информация о собранном пакете (dpkg-deb -I)
|
||||
make deb-contents # список файлов пакета (dpkg-deb -c)
|
||||
make deb-clean # очистка временных файлов сборки пакета
|
||||
```
|
||||
|
||||
**Версионирование через git:** версия пакета = `<база>-build<N>`, где база берётся из `Version:` в [debian/control](../debian/control) (сейчас `1.0.0`), а `N` = `git rev-list --count HEAD` (число коммитов). Итоговый файл: `build/gpio-monitor-server_1.0.0-build<N>_arm64.deb`. Каждый коммит автоматически увеличивает номер сборки.
|
||||
|
||||
`make deb` выполняет `clean → build-frontend → build-backend → prepare-debian → dpkg-deb --build`. В пакет попадают: бинарник, веб-файлы (копия для справки — сервер использует встроенные), unit systemd, конфиг, утилита `gpio-logs`, maintainer-скрипты из [debian/](../debian/). Что происходит при установке — в [operations.md](operations.md#установка-из-deb-пакета).
|
||||
|
||||
## Запуск без железа (эмуляция)
|
||||
|
||||
FIFO и поток данных можно смоделировать на любой Linux-машине:
|
||||
|
||||
```bash
|
||||
mkfifo /tmp/gpio_pipe
|
||||
python3 scripts/emulator.py scripts/array.txt /tmp/gpio_pipe # генератор данных
|
||||
make dev # сервер (в другом терминале)
|
||||
```
|
||||
|
||||
Вспомогательные скрипты [scripts/](../scripts/):
|
||||
|
||||
| Скрипт | Назначение |
|
||||
|---|---|
|
||||
| `emulator.py` | Эмулятор капчера: читает массив байт из файла (формат `[0xE0, 0x01, ...]`) и пишет их в pipe/stdout с заданной задержкой, с переподключением и повтором |
|
||||
| `array.txt` | Пример массива данных для эмулятора |
|
||||
| `imi_wire.py` | Имитатор параллельной шины на **реальных GPIO** (gpiod, Raspberry Pi 4): выставляет байт на пины данных и дёргает строб WR — для теста настоящего `gpio-interrupt` |
|
||||
| `emulatohackrf.sh` | Связка эмулятора с передатчиком HackRF (`hackrf-frame64-tx`): передача байтов GPIO по радиоканалу |
|
||||
| `vu_meter.py` | Консольный VU-метр микрофона (sounddevice/numpy) — независимая проверка аудиотракта |
|
||||
| `view_logs.sh` | Сводная статистика dev-логов (`~/.local/share/gpio-monitoring/logs`) |
|
||||
|
||||
Пути внутри `emulatohackrf.sh` и systemd-юнитов захардкожены под конкретное устройство (`/home/user/...`) — правьте под своё окружение.
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
make test # go test -v ./...
|
||||
```
|
||||
|
||||
Сейчас тестами покрыт только retention: [internal/logger/retention_test.go](../internal/logger/retention_test.go) (очистка по возрасту/размеру, minKeepFiles, устойчивость к скачку часов и битым именам). Остальные пакеты тестов не имеют — см. [tech-debt.md](tech-debt.md#тесты-и-ci). CI в репозитории нет — прогоняйте `make test` и `go vet ./...` перед коммитом вручную.
|
||||
|
||||
## Соглашения
|
||||
|
||||
- **Язык** — русский: комментарии, логи, сообщения об ошибках, документация.
|
||||
- **Конфигурация** — только CLI-флаги (без env-переменных и конфиг-файлов); новые параметры добавляются флагом в `main()` и полем в `logger.Config`.
|
||||
- **Зависимости** — Go-код держится на стандартной библиотеке; прежде чем добавить стороннюю зависимость, убедитесь, что она действительно необходима.
|
||||
- **Структура**: `cmd/server` — точка входа и HTTP-слой; `internal/adapter` — буфер; `internal/pipe` — приём данных; `internal/logger` — хранение/события; `internal/audio` — микрофон. Фронтенд — [frontend.md](frontend.md).
|
||||
- **Целевая платформа** — linux/arm64; сборка и на других платформах должна оставаться рабочей (кросс-цели Makefile).
|
||||
- Форматы данных и API описаны в [data-formats.md](data-formats.md) и [api.md](api.md) — при изменении кода обновляйте эти документы.
|
||||
79
docs/frontend.md
Normal file
79
docs/frontend.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Фронтенд (веб-дашборд)
|
||||
|
||||
Устройство встроенного веб-интерфейса: страницы, модули TypeScript, сборка, взаимодействие с API.
|
||||
|
||||
**Аудитория:** разработчики.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Обзор](#обзор)
|
||||
- [Страницы](#страницы)
|
||||
- [Модули TypeScript](#модули-typescript)
|
||||
- [Взаимодействие с API](#взаимодействие-с-api)
|
||||
- [Сборка](#сборка)
|
||||
- [Цикл разработки](#цикл-разработки)
|
||||
|
||||
## Обзор
|
||||
|
||||
Фронтенд — SPA на **ванильном TypeScript без фреймворков и рантайм-зависимостей**: ES-модули, Canvas для графиков, Web Audio API для звуковой индикации. Исходники — [cmd/server/web/js/](../cmd/server/web/js/), компилируются `tsc` в `web/dist/` (ES2020, strict), стили — `web/css/`, разметка — `web/*.html`.
|
||||
|
||||
Готовые файлы **вшиваются в бинарник сервера** (`go:embed web/dist web/css web/fonts web/*.html web/*.png`) и раздаются с корня `/` — отдельного веб-сервера для фронтенда нет.
|
||||
|
||||
## Страницы
|
||||
|
||||
| Страница | Назначение | Точка входа JS |
|
||||
|---|---|---|
|
||||
| `index.html` | Редирект на дашборд | — |
|
||||
| `dashboard.html` | Мониторинг в реальном времени: статус, каналы, график, VU-метр звука, камера | `dist/app.js` |
|
||||
| `logs.html` | Просмотр исторических данных: список `.bin`-файлов, сэмплы с пагинацией, события | `dist/logs.js` |
|
||||
|
||||
## Модули TypeScript
|
||||
|
||||
Карта модулей `js/` (в скобках — размер на момент написания):
|
||||
|
||||
| Модуль | Роль |
|
||||
|---|---|
|
||||
| `app.ts` (434 строки) | Оркестратор дашборда: инициализация всех модулей, цикл опроса сервера (каждые 500 мс), раздача данных в state/ui/chart/audio |
|
||||
| `data.ts` (32) | API-слой дашборда: `fetchHealth()`, `fetchHistory()` + типы ответов |
|
||||
| `state.ts` (222) | Состояние дашборда: история сигнала, peak-holder'ы амплитуды и уровня сигнала с таймерами удержания |
|
||||
| `ui.ts` (414) | Каталог DOM-элементов (`DOM`) и все операции обновления интерфейса: статус, бары, каналы, индикаторы |
|
||||
| `chart.ts` (137) | Отрисовка графика истории на Canvas (сетка, шкала 0–63) |
|
||||
| `audio.ts` (306) | `AudioEngine` — звуковая индикация через Web Audio API: тон зависит от уровня силы (600/1000/1200 Гц), громкость регулируется |
|
||||
| `camera.ts` (164) | Панель камеры: подключение/отключение MJPEG `<img src="/api/cam">`, перекрестие, периодическая проверка потока |
|
||||
| `layout.ts` (235) | Разделитель панелей (drag-resize), открытие/закрытие правой панели |
|
||||
| `accordion.ts` (23) | Сворачиваемый блок «Принятые данные» |
|
||||
| `logs.ts` (747) | Вся страница logs.html: список файлов, таблица сэмплов и событий с пагинацией, график по файлу, автообновление |
|
||||
|
||||
Зависимости между модулями — однонаправленные: `app.ts` импортирует остальные; `logs.ts` автономен (использует только `chart.ts`). Типы ответов API объявлены локально в `data.ts` и `logs.ts` (дублирование — см. [tech-debt.md](tech-debt.md#фронтенд-cmdserverwebjs)).
|
||||
|
||||
## Взаимодействие с API
|
||||
|
||||
Полная спецификация — [api.md](api.md).
|
||||
|
||||
| Модуль | Эндпоинты |
|
||||
|---|---|
|
||||
| `data.ts` (дашборд) | `GET /api/health`, `GET /api/history` — опрос каждые 500 мс из `app.ts` |
|
||||
| `camera.ts` | `GET /api/cam` (MJPEG через `<img>`) |
|
||||
| `logs.ts` | `GET /api/log/files`, `GET /api/log/data`, `GET /api/log/events`, `GET /api/health` (серверное время) |
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
cd cmd/server/web
|
||||
npm install # однократно (единственная dev-зависимость — typescript)
|
||||
npm run build # tsc: js/*.ts → dist/*.js
|
||||
npm run watch # tsc --watch
|
||||
npm run clean # удалить dist/
|
||||
```
|
||||
|
||||
Конфигурация — [tsconfig.json](../cmd/server/web/tsconfig.json): target/module ES2020, `strict: true`, `rootDir: js`, `outDir: dist`. Каталоги `dist/` и `node_modules/` в git не хранятся.
|
||||
|
||||
**Важно:** без собранного `dist/` не соберётся и Go-сервер (`go:embed`) — см. [development.md](development.md#порядок-сборки).
|
||||
|
||||
## Цикл разработки
|
||||
|
||||
1. Терминал 1: `cd cmd/server/web && npm run watch` — пересборка TS при каждом сохранении;
|
||||
2. Терминал 2: `make dev` — Go-сервер;
|
||||
3. После изменения `.ts` — обновить страницу; после изменения `.html`/`.css` при работе через встроенные файлы — перезапустить сервер (файлы вшиваются на этапе сборки, `make dev` через `go run` перечитает их при рестарте).
|
||||
|
||||
Данные без железа — эмулятор: [development.md](development.md#запуск-без-железа-эмуляция).
|
||||
255
docs/operations.md
Normal file
255
docs/operations.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Руководство администратора
|
||||
|
||||
Установка, настройка и обслуживание GPIO Monitor на Raspberry Pi.
|
||||
|
||||
**Аудитория:** администраторы и операторы.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Требования](#требования)
|
||||
- [Установка из deb-пакета](#установка-из-deb-пакета)
|
||||
- [Ручная установка](#ручная-установка)
|
||||
- [Параметры командной строки](#параметры-командной-строки)
|
||||
- [Firewall (nftables)](#firewall-nftables)
|
||||
- [Wi-Fi Access Point](#wi-fi-access-point)
|
||||
- [Камера (go2rtc)](#камера-go2rtc)
|
||||
- [Микрофон](#микрофон)
|
||||
- [Логи: где лежат и как смотреть](#логи-где-лежат-и-как-смотреть)
|
||||
- [Диагностика проблем](#диагностика-проблем)
|
||||
|
||||
## Требования
|
||||
|
||||
- Raspberry Pi (целевая платформа — ARM64, Raspberry Pi OS Bookworm);
|
||||
- C-программа захвата `gpio-interrupt` (WiringPi) на устройстве — поставляется отдельно, в репозитории её нет;
|
||||
- `alsa-utils` (`arecord`) — для монитора уровня звука;
|
||||
- go2rtc — для видеопотока камеры (бинарник в [scripts/go2rtc](../scripts/go2rtc));
|
||||
- systemd.
|
||||
|
||||
Назначение GPIO-пинов:
|
||||
|
||||
```
|
||||
WR/STROBE = GPIO27
|
||||
|
||||
DATA BUS:
|
||||
D0 = GPIO21 D4 = GPIO25
|
||||
D1 = GPIO7 D5 = GPIO24
|
||||
D2 = GPIO6 D6 = GPIO23
|
||||
D3 = GPIO5 D7 = GPIO22
|
||||
```
|
||||
|
||||
## Установка из deb-пакета
|
||||
|
||||
Рекомендуемый способ развёртывания. Сборка пакета — см. [development.md](development.md#сборка-deb-пакета); итоговый файл: `build/gpio-monitor-server_<база>-build<N>_arm64.deb`, где `N` — число git-коммитов (например `gpio-monitor-server_1.0.0-build83_arm64.deb`).
|
||||
|
||||
```bash
|
||||
sudo dpkg -i build/gpio-monitor-server_1.0.0-build83_arm64.deb
|
||||
```
|
||||
|
||||
Пакет при установке ([debian/preinst](../debian/preinst), [debian/postinst](../debian/postinst)):
|
||||
|
||||
- создаёт системного пользователя `gpio-monitor` (группы `gpio`, `dialout`) и каталоги `/var/log/gpio-monitoring`, `/var/lib/gpio-monitoring`, `/opt/gpio-monitoring/web`;
|
||||
- ставит бинарник в `/usr/bin/gpio-monitor-server` и утилиту `gpio-logs` в `/usr/bin/gpio-logs`;
|
||||
- кладёт unit `gpio-monitor-server.service` в `/etc/systemd/system/` и справочный конфиг в `/etc/gpio-monitoring/config`;
|
||||
- включает и **сразу запускает** сервис.
|
||||
|
||||
Unit [debian/gpio-monitor-server.service](../debian/gpio-monitor-server.service) запускает сервер с параметрами `-retention-hours=72 -retention-mb=2000 -retention-interval=60 -rotation-check-interval=1 -buffer-size=10240 -human-log-interval=5` (обратите внимание: retention здесь 72 часа, а не встроенные по умолчанию 48). Чтобы изменить параметры, отредактируйте `ExecStart` в unit-файле и выполните `sudo systemctl daemon-reload && sudo systemctl restart gpio-monitor-server`.
|
||||
|
||||
> Файл `/etc/gpio-monitoring/config` — справочный: сервис его **не читает**, параметры берутся только из `ExecStart` (см. [tech-debt.md](tech-debt.md#пакеты-internal)).
|
||||
|
||||
Управление:
|
||||
|
||||
```bash
|
||||
sudo systemctl status gpio-monitor-server
|
||||
sudo systemctl restart gpio-monitor-server
|
||||
gpio-logs -f # журнал сервиса
|
||||
```
|
||||
|
||||
Удаление: `sudo apt remove gpio-monitor-server`; полная очистка с логами и пользователем: `sudo apt purge gpio-monitor-server`.
|
||||
|
||||
## Ручная установка
|
||||
|
||||
### 1. Зависимости и сборка
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y golang git build-essential wiringpi nodejs npm alsa-utils
|
||||
git clone <your-repo> && cd golang
|
||||
make init && make build # подробности — development.md
|
||||
```
|
||||
|
||||
### 2. Канал данных: systemd socket-activation
|
||||
|
||||
FIFO `/tmp/gpio_pipe` и программу захвата запускает systemd ([scripts/monitor-gpio.socket](../scripts/monitor-gpio.socket), [scripts/monitor-gpio.service](../scripts/monitor-gpio.service)): socket-юнит создаёт FIFO, service-юнит направляет stdout `gpio-interrupt` в него.
|
||||
|
||||
```bash
|
||||
sudo cp scripts/monitor-gpio.service scripts/monitor-gpio.socket /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable monitor-gpio.socket monitor-gpio.service
|
||||
sudo systemctl start monitor-gpio.service
|
||||
```
|
||||
|
||||
> Пути в `monitor-gpio.service` (`/home/user/WiringPi/examples/gpio-interrupt`) и владелец FIFO в `monitor-gpio.socket` (`SocketUser=user`) заданы под конкретное устройство — проверьте их перед установкой.
|
||||
|
||||
### 3. Go-сервер как сервис
|
||||
|
||||
Шаблон — [scripts/go2monitor.service](../scripts/go2monitor.service) (запуск собранного бинарника от обычного пользователя). Поправьте `WorkingDirectory` и путь в `ExecStart` под своё расположение проекта, затем:
|
||||
|
||||
```bash
|
||||
sudo cp scripts/go2monitor.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now go2monitor.service
|
||||
```
|
||||
|
||||
Либо запустите вручную:
|
||||
|
||||
```bash
|
||||
./build/gpio-monitor-server -pipe /tmp/gpio_pipe -retention-hours=72 -retention-mb=2000
|
||||
```
|
||||
|
||||
### 4. Проверка
|
||||
|
||||
```bash
|
||||
sudo systemctl status monitor-gpio.service
|
||||
curl http://localhost:8080/api/health
|
||||
```
|
||||
|
||||
Дашборд: `http://<IP>:8080/` (редирект на `dashboard.html`), просмотр истории: `http://<IP>:8080/logs.html`.
|
||||
|
||||
## Параметры командной строки
|
||||
|
||||
Полный список флагов `gpio-monitor-server` (источник — [cmd/server/main.go](../cmd/server/main.go)):
|
||||
|
||||
| Параметр | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| `-pipe` | `/tmp/gpio_pipe` | Путь к FIFO pipe |
|
||||
| `-port` | `:8080` | Адрес/порт веб-сервера |
|
||||
| `-retention-hours` | `48` | Часы хранения `.bin`-логов (0 = отключено) |
|
||||
| `-retention-mb` | `5000` | Максимальный суммарный размер `.bin`-логов, МБ (0 = отключено) |
|
||||
| `-retention-interval` | `15` | Интервал проверки retention, мин |
|
||||
| `-rotation-check-interval` | `1` | Интервал проверки ротации, мин |
|
||||
| `-buffer-size` | `10240` | Размер кольцевого буфера, элементов |
|
||||
| `-silence-5min` | `5` | Порог тишины для события `ТИШИНА_5МИН`, мин |
|
||||
| `-silence-10min` | `10` | Порог тишины для события `ТИШИНА_10МИН`, мин |
|
||||
| `-watchdog-interval` | `1` | Интервал проверки watchdog, мин |
|
||||
| `-alert-cooldown` | `10` | Анти-спам между одинаковыми алертами, сек |
|
||||
| `-human-log-interval` | `5` | Интервал записи human-логов, сек |
|
||||
| `-camera-url` | `http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg` | URL MJPEG-потока для прокси `/api/cam` |
|
||||
|
||||
## Firewall (nftables)
|
||||
|
||||
На Raspberry Pi OS Bookworm по умолчанию активен nftables, блокирующий входящие порты, кроме SSH (22).
|
||||
|
||||
Разрешить порт 8080:
|
||||
|
||||
```bash
|
||||
sudo nft add rule inet filter input tcp dport 8080 accept
|
||||
|
||||
# Проверить и сохранить
|
||||
sudo nft list ruleset
|
||||
sudo nft list ruleset | sudo tee /etc/nftables.conf > /dev/null
|
||||
sudo systemctl enable nftables
|
||||
sudo systemctl restart nftables
|
||||
```
|
||||
|
||||
Ограничить доступ:
|
||||
|
||||
```bash
|
||||
# Только локальная сеть
|
||||
sudo nft add rule inet filter input ip saddr 10.1.1.0/24 tcp dport 8080 accept
|
||||
# Только конкретный IP
|
||||
sudo nft add rule inet filter input ip saddr 192.168.1.100 tcp dport 8080 accept
|
||||
# Только через Wi-Fi AP
|
||||
sudo nft add rule inet filter input iifname "wlan0" tcp dport 8080 accept
|
||||
```
|
||||
|
||||
## Wi-Fi Access Point
|
||||
|
||||
Raspberry Pi может работать точкой доступа:
|
||||
|
||||
- SSID: `fix_me`, IP устройства: `192.168.77.1`;
|
||||
- подключение: `ssh user@192.168.77.1`;
|
||||
- дашборд: `http://192.168.77.1:8080`.
|
||||
|
||||
## Камера (go2rtc)
|
||||
|
||||
Видеопоток отдаёт go2rtc (конфиг — [scripts/go2rtc.yaml](../scripts/go2rtc.yaml)): поток `cam_mjpeg` — MJPEG 640×480 15 fps с `/dev/video0` через ffmpeg, API на порту `1984` (также RTSP `:8554`, WebRTC `:8555`).
|
||||
|
||||
```bash
|
||||
./scripts/go2rtc -config scripts/go2rtc.yaml
|
||||
```
|
||||
|
||||
Проверка: `http://<IP>:1984` (встроенный интерфейс go2rtc), `http://<IP>:1984/api/stream.mjpeg?src=cam_mjpeg` (прямой поток). Go-сервер проксирует этот поток на `/api/cam`.
|
||||
|
||||
## Микрофон
|
||||
|
||||
Уровень звука снимается через `arecord` (пакет `alsa-utils`); USB-микрофон находится автоматически. Проверка:
|
||||
|
||||
```bash
|
||||
arecord -l
|
||||
# **** List of CAPTURE Hardware Devices ****
|
||||
# card 3: Device [USB PnP Sound Device], device 0: USB Audio [USB Audio]
|
||||
```
|
||||
|
||||
Если список пуст — микрофон не определился; сервер при этом работает нормально, но `/api/health` отдаёт `sound_level: 0`. Пользователь, от которого запущен сервис, должен состоять в группе `audio` (`sudo usermod -aG audio <user>`). При физическом отвале микрофона сервер сам пытается восстановить захват каждые 5 секунд.
|
||||
|
||||
## Логи: где лежат и как смотреть
|
||||
|
||||
Пути зависят от режима (подробно — [data-formats.md](data-formats.md#пути-хранения)):
|
||||
|
||||
| Файл | Пакетная установка | Ручной запуск (dev) |
|
||||
|---|---|---|
|
||||
| Бинарные данные `gpio-*.bin` | `/var/log/gpio-monitoring/data/` | `~/.local/share/gpio-monitoring/logs/data/` |
|
||||
| События | `/var/log/gpio-monitoring/events.log`, `events_human.log` | `~/.local/share/gpio-monitoring/logs/...` |
|
||||
| GPIO human-лог | `/var/log/gpio-monitoring/gpio_human.log` | `~/.local/share/gpio-monitoring/logs/gpio_human.log` |
|
||||
| Журнал сервиса | `journalctl -u gpio-monitor-server` | stdout |
|
||||
|
||||
Просмотр:
|
||||
|
||||
```bash
|
||||
# Пакетная установка — утилита gpio-logs:
|
||||
gpio-logs -f # журнал сервиса (journalctl)
|
||||
gpio-logs -e # события
|
||||
gpio-logs -g # GPIO human-лог
|
||||
gpio-logs -d # сырые бинарные данные (xxd)
|
||||
|
||||
# Dev-режим:
|
||||
./scripts/view_logs.sh # сводная статистика логов
|
||||
```
|
||||
|
||||
Место на диске контролируется retention (см. флаги `-retention-*`); при значениях по умолчанию deb-юнита — не более 2000 МБ и 72 часов бинарных данных.
|
||||
|
||||
## Диагностика проблем
|
||||
|
||||
### Dashboard не открывается
|
||||
|
||||
1. Сервер запущен?
|
||||
```bash
|
||||
ps aux | grep gpio-monitor-server
|
||||
sudo systemctl status gpio-monitor-server # или go2monitor / monitor-gpio при ручной установке
|
||||
```
|
||||
2. Firewall: `sudo nft list ruleset | grep 8080`
|
||||
3. Сервер слушает нужный интерфейс: `sudo ss -tlnp | grep 8080` (должно быть `*:8080` или `0.0.0.0:8080`)
|
||||
4. Маршрутизация: `ip route show`
|
||||
5. Логи:
|
||||
```bash
|
||||
sudo journalctl -u gpio-monitor-server -f
|
||||
sudo tail -f /var/log/gpio-monitoring/events_human.log # пакетная установка
|
||||
tail -f ~/.local/share/gpio-monitoring/logs/events_human.log # dev-режим
|
||||
```
|
||||
6. FIFO существует: `ls -la /tmp/gpio_pipe`
|
||||
|
||||
### Статус «Нет связи» на дашборде
|
||||
|
||||
Данные не поступают дольше 15 секунд. Проверьте цепочку: `monitor-gpio.service` запущен → FIFO существует → в `events_human.log` нет свежих `PIPE_НЕ_НАЙДЕН`/`PIPE_ОТКЛЮЧЕН` → события `ТИШИНА_5МИН` укажут, что pipe жив, но данных нет (проблема на стороне захвата/шины).
|
||||
|
||||
### Индикатор «🔊 ЗВУК» не реагирует
|
||||
|
||||
1. `which arecord && arecord -l` — arecord установлен и видит микрофон;
|
||||
2. в журнале сервера при старте нет строки `Аудио-монитор не запущен (микрофон недоступен?)`;
|
||||
3. пользователь сервиса в группе `audio`: `groups <user>`.
|
||||
|
||||
### Камера не показывает
|
||||
|
||||
1. go2rtc запущен и отвечает: `curl http://localhost:1984/api/streams`;
|
||||
2. `/dev/video0` существует;
|
||||
3. `/api/stream` возвращает `available: true`. Учтите: проверка доступности всегда идёт на `localhost:1984` независимо от `-camera-url` (см. [api.md](api.md#известные-особенности)).
|
||||
59
docs/tech-debt.md
Normal file
59
docs/tech-debt.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Реестр технического долга
|
||||
|
||||
Известные проблемы и упрощения, принятые в текущей реализации. Реестр ведётся, чтобы долг был видим и осознан; исправления — отдельные задачи. При закрытии пункта удаляйте его отсюда, при появлении нового — добавляйте с указанием места и влияния.
|
||||
|
||||
**Аудитория:** разработчики.
|
||||
|
||||
Состояние на 2026-07-17. Маркеров `TODO`/`FIXME` в коде нет — этот файл единственный источник.
|
||||
|
||||
## Go-сервер (cmd/server/main.go)
|
||||
|
||||
| # | Проблема | Где | Влияние | Направление исправления |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Монолит: 8 HTTP-хендлеров, разбор флагов, чтение `.bin`, прокси камеры — всё в одном файле на ~750 строк | `main.go` | Затрудняет навигацию и тестирование хендлеров | Вынести HTTP-слой в `internal/api`, чтение `.bin` — в `internal/logger` |
|
||||
| 2 | Мёртвый код: `handleCamProxy` не вызывается (роутер использует `handleCamProxyWithURL`) | `main.go:474` | Путает при чтении, дублирует логику | Удалить после подтверждения |
|
||||
| 3 | `HandleStream` проверяет доступность камеры по захардкоженному `localhost:1984`, игнорируя `-camera-url` | `main.go:460` | При нестандартном URL камеры `available` врёт | Строить URL проверки из `-camera-url` |
|
||||
| 4 | Разбор байта GPIO продублирован: `HandleLogData` сдвигает биты сам вместо `logger.ParseGPIO` | `main.go:297–298` ↔ `internal/logger/parser.go` | Риск рассинхронизации формата | Использовать `ParseGPIO` |
|
||||
| 5 | `/api/log/data` читает весь `.bin`-файл в память до пагинации | `main.go:278–308` | На больших файлах — всплеск памяти на каждый запрос | Читать нужный диапазон по смещению (записи фиксированные, 9 байт) |
|
||||
| 6 | CORS открыт для всех источников (`*`) | `main.go:522–535` | Приемлемо для изолированной сети; риск при выходе наружу | Осознанное решение зафиксировано; при необходимости — allowlist |
|
||||
| 7 | `/api/log/files` возвращает `null` вместо `[]` при отсутствии файлов; `/api/latest` отдаёт 200 с `{"error":"no data"}` | `main.go:67,105`, `main.go:435` | Клиентам нужны доп. проверки | Инициализировать слайс; вернуть 204/404 либо задокументированную схему |
|
||||
|
||||
## Пакеты internal/
|
||||
|
||||
| # | Проблема | Где | Влияние | Направление исправления |
|
||||
|---|---|---|---|---|
|
||||
| 8 | Два параллельных механизма расчёта скорости пишут в одни атомики: периодический (250 мс) и скользящее окно (1 с); публичный `GetWindowSpeed()` не используется | `internal/adapter/buffer.go:77–162` | Значение `bytes_per_sec` зависит от того, кто записал последним; лишний код и память (`recentBytes`) | Оставить один механизм |
|
||||
| 9 | Конфигурация размазана: значения по умолчанию во флагах (`retention-hours=48`), в deb-юните (`72`), в `scripts/go2monitor.service` (`72`) и в справочном `/etc/gpio-monitoring/config`, который сервис **не читает** | `main.go`, `debian/gpio-monitor-server.service`, `debian/gpio-monitor-server.conf` | Непонятно, что «истина»; правка конфига не влияет на сервис | Либо читать `EnvironmentFile=/etc/gpio-monitoring/config` в юните, либо удалить конфиг-файл |
|
||||
| 10 | `DataLogger.flush` молча глотает ошибку записи (комментарий «тут должен быть event» в коде) | `internal/logger/data_logger.go:66–70` | Потеря данных при полном диске останется незамеченной | Прокинуть EventLogger и писать событие |
|
||||
|
||||
## Тесты и CI
|
||||
|
||||
| # | Проблема | Где | Влияние | Направление исправления |
|
||||
|---|---|---|---|---|
|
||||
| 11 | Единственный тест-файл на проект — retention; без тестов `parser.go`, `buffer.go` (конкурентность), `rotation.go`, `data_logger.go` (бинарный формат), `reader.go` | `internal/logger/retention_test.go` | Регрессии форматов/конкурентности не ловятся | Начать с parser (тривиально) и data_logger (формат 9 байт) |
|
||||
| 12 | CI отсутствует (нет `.gitea/workflows/`) | — | Сборка и тесты не проверяются автоматически | Gitea Actions: `make build-frontend`, `go vet`, `go test` |
|
||||
|
||||
## Сборка и инфраструктура
|
||||
|
||||
| # | Проблема | Где | Влияние | Направление исправления |
|
||||
|---|---|---|---|---|
|
||||
| 13 | Цели `docker-build`/`docker-run` есть, Dockerfile — нет | `Makefile:249–256` | Цели заведомо падают | Удалить цели или добавить Dockerfile |
|
||||
| 14 | C-программа захвата `gpio-interrupt` — ключевой компонент системы — не версионируется в репозитории (живёт на устройстве в `/home/user/WiringPi/examples`) | `scripts/monitor-gpio.service` | Невоспроизводимость: систему нельзя собрать целиком из репозитория | Добавить исходник в репозиторий (например `capture/gpio-interrupt.c`) |
|
||||
| 15 | `go build` требует предварительно собранного `web/dist` (`go:embed`) — чистый checkout не собирается командой `go build ./...` | `cmd/server/main.go:28` | Неочевидная ошибка для новичка; ломает go-инструментарий на чистом дереве | Задокументировано ([development.md](development.md#порядок-сборки)); вариант — коммитить заглушку `dist/.keep` с `embed` через `all:` |
|
||||
| 16 | Захардкоженные пути под конкретное устройство: `build.sh` и `start-server.sh` (`~/temp/golang`), `emulatohackrf.sh`, systemd-юниты в `scripts/` (`/home/user/...`) | `build.sh`, `start-server.sh`, `scripts/*` | Скрипты не переносимы | Параметризовать или пометить как шаблоны |
|
||||
|
||||
## Фронтенд (cmd/server/web/js/)
|
||||
|
||||
| # | Проблема | Где | Влияние | Направление исправления |
|
||||
|---|---|---|---|---|
|
||||
| 17 | Крупные модули без разбивки: `logs.ts` (747 строк), `app.ts` (434), `ui.ts` (414) | `js/` | Сложно поддерживать | Разбить `logs.ts` на api/таблицы/пагинацию |
|
||||
| 18 | Типы ответов API объявлены дважды: в `data.ts` и `logs.ts` | `js/data.ts`, `js/logs.ts` | Рассинхронизация с сервером ловится только вручную | Общий `js/api-types.ts` |
|
||||
| 19 | Нет линтера/форматтера (eslint/prettier) и ни одного теста фронтенда; `make fmt` вызывает несуществующий `npm run format` | `package.json`, `Makefile:112` | Стиль и регрессии не контролируются | Добавить prettier + script `format` |
|
||||
| 20 | `logs.ts:284` передаёт в `/api/log/events` параметр `order=newest_first`, который сервер не читает | `js/logs.ts:284` | Мусорный параметр, вводит в заблуждение | Убрать параметр |
|
||||
|
||||
## SDR/
|
||||
|
||||
| # | Проблема | Где | Влияние | Направление исправления |
|
||||
|---|---|---|---|---|
|
||||
| 21 | makefile ожидает `receiver.c`, реальный файл — `reciever.c` (опечатка): цель сборки приёмника не срабатывает | `SDR/makefile`, `SDR/reciever.c` | `make` собирает только передатчик; приёмник — только вручную (обходная команда в [SDR/README.md](../SDR/README.md)) | Переименовать файл в `receiver.c` (или поправить makefile) |
|
||||
| 22 | Статус подпроекта не определён: SDR не связан с основной системой ни сборкой, ни CI | `SDR/` | Непонятно, поддерживается ли код | Зафиксировать статус в SDR/README (актуален/эксперимент/заморожен) |
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package adapter содержит кольцевой буфер последних принятых байт GPIO —
|
||||
// источник данных для HTTP API (/api/latest, /api/history, /api/health).
|
||||
package adapter
|
||||
|
||||
import (
|
||||
@@ -6,6 +8,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RingBuffer — потокобезопасный кольцевой буфер байт фиксированного размера.
|
||||
// Помимо самих данных ведёт статистику приёма: время последней записи,
|
||||
// суммарные счётчики и текущую скорость (байт/с и бит/с).
|
||||
type RingBuffer struct {
|
||||
mu sync.RWMutex
|
||||
data []byte
|
||||
@@ -34,7 +39,7 @@ type timestampedByte struct {
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// =====================
|
||||
// NewRingBuffer создаёт буфер на size элементов.
|
||||
func NewRingBuffer(size int) *RingBuffer {
|
||||
rb := &RingBuffer{
|
||||
data: make([]byte, size),
|
||||
@@ -48,7 +53,8 @@ func NewRingBuffer(size int) *RingBuffer {
|
||||
return rb
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Write добавляет байт в буфер (при переполнении затирается самый старый)
|
||||
// и обновляет статистику: счётчики, время последней записи, скорость.
|
||||
func (rb *RingBuffer) Write(b byte) {
|
||||
rb.mu.Lock()
|
||||
|
||||
@@ -98,7 +104,8 @@ func (rb *RingBuffer) updateSpeedPeriodically(now time.Time) {
|
||||
rb.lastCalcBytes = currentTotal
|
||||
}
|
||||
|
||||
// Скользящее окно
|
||||
// addTimestampedByte ведёт скользящее окно временных меток за последнюю секунду
|
||||
// и пересчитывает по нему скорость (перетирая значение периодического механизма).
|
||||
func (rb *RingBuffer) addTimestampedByte(now time.Time) {
|
||||
rb.recentMu.Lock()
|
||||
defer rb.recentMu.Unlock()
|
||||
@@ -131,7 +138,8 @@ func (rb *RingBuffer) addTimestampedByte(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// Получить скорость на основе скользящего окна (альтернативный метод)
|
||||
// GetWindowSpeed возвращает скорость приёма, рассчитанную по скользящему окну
|
||||
// в 1 секунду. Альтернатива значениям из Stats; текущим API не используется.
|
||||
func (rb *RingBuffer) GetWindowSpeed() (bytesPerSec, bitsPerSec float64) {
|
||||
rb.recentMu.Lock()
|
||||
defer rb.recentMu.Unlock()
|
||||
@@ -161,12 +169,12 @@ func (rb *RingBuffer) GetWindowSpeed() (bytesPerSec, bitsPerSec float64) {
|
||||
return
|
||||
}
|
||||
|
||||
// =====================
|
||||
// LastWriteTime возвращает время последней записи в буфер.
|
||||
func (rb *RingBuffer) LastWriteTime() time.Time {
|
||||
return time.Unix(0, rb.lastWrite.Load())
|
||||
}
|
||||
|
||||
// =====================
|
||||
// GetLatest возвращает последний записанный байт; false — если буфер пуст.
|
||||
func (rb *RingBuffer) GetLatest() (byte, bool) {
|
||||
rb.mu.RLock()
|
||||
defer rb.mu.RUnlock()
|
||||
@@ -183,7 +191,7 @@ func (rb *RingBuffer) GetLatest() (byte, bool) {
|
||||
return rb.data[idx], true
|
||||
}
|
||||
|
||||
// =====================
|
||||
// GetLast возвращает до n последних байт в порядке от старых к новым.
|
||||
func (rb *RingBuffer) GetLast(n int) []byte {
|
||||
rb.mu.RLock()
|
||||
defer rb.mu.RUnlock()
|
||||
@@ -207,12 +215,13 @@ func (rb *RingBuffer) GetLast(n int) []byte {
|
||||
return res
|
||||
}
|
||||
|
||||
// =====================
|
||||
// IsAlive сообщает, была ли запись в буфер за последние timeout.
|
||||
func (rb *RingBuffer) IsAlive(timeout time.Duration) bool {
|
||||
return time.Since(time.Unix(0, rb.lastWrite.Load())) < timeout
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Stats возвращает статистику буфера для /api/health. Ключи: size, filled,
|
||||
// last_write, total_bytes, total_bits, bytes_per_sec, bits_per_sec.
|
||||
func (rb *RingBuffer) Stats() map[string]interface{} {
|
||||
rb.mu.RLock()
|
||||
filled := rb.count
|
||||
|
||||
222
internal/audio/monitor.go
Normal file
222
internal/audio/monitor.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Package audio измеряет уровень звука с микрофона через arecord (ALSA):
|
||||
// USB-устройство находится автоматически, PCM-поток читается блоками по 100 мс,
|
||||
// уровень считается как RMS и публикуется по шкале 0–100 (поле sound_level
|
||||
// в /api/health). Падение arecord не фатально — захват перезапускается.
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Monitor — фоновый измеритель уровня звука с микрофона.
|
||||
type Monitor struct {
|
||||
mu sync.RWMutex
|
||||
currentLevel int
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewMonitor создаёт монитор; захват запускается отдельным вызовом Start.
|
||||
func NewMonitor() *Monitor {
|
||||
return &Monitor{}
|
||||
}
|
||||
|
||||
// GetLevel возвращает текущий уровень звука по шкале 0–100
|
||||
// (RMS/32768 × 100 × 4 с отсечкой сверху); 0 — если захват не работает.
|
||||
func (m *Monitor) GetLevel() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.currentLevel
|
||||
}
|
||||
|
||||
// Start запускает захват звука в фоне. Ошибка (микрофон недоступен) не
|
||||
// критична для вызывающего кода — сервер продолжает работать без звука.
|
||||
func (m *Monitor) Start() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.cancel = cancel
|
||||
|
||||
if err := m.startCapture(ctx); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startCapture запускает arecord и горутину чтения потока. Может вызываться
|
||||
// повторно при пересборке пайпа после сбоя (см. handleCrash).
|
||||
func (m *Monitor) startCapture(ctx context.Context) error {
|
||||
// Используем гибкий поиск USB устройств
|
||||
device := m.findUSBDevice()
|
||||
log.Printf("Используем аудиоустройство: %s", device)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "arecord",
|
||||
"-D", device,
|
||||
"-t", "raw",
|
||||
"-c", "1",
|
||||
"-r", "16000",
|
||||
"-f", "S16_LE")
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Аудио-монитор запущен с устройством: %s", device)
|
||||
|
||||
m.wg.Add(1)
|
||||
go m.readLoop(ctx, stdout)
|
||||
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Monitor) readLoop(ctx context.Context, stdout io.ReadCloser) {
|
||||
defer m.wg.Done()
|
||||
|
||||
buffer := make([]byte, 3200)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
_, err := io.ReadFull(stdout, buffer)
|
||||
if err != nil {
|
||||
// Контекст отменён (Stop()) — пайп закрыт намеренно, тихо выходим.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Дескриптор закрыт (например, cmd.Wait() уже реаппнул процесс) —
|
||||
// arecord умер сам по себе, пробуем восстановиться.
|
||||
if strings.Contains(err.Error(), "file already closed") {
|
||||
log.Printf("Аудио-пайп закрыт: %v", err)
|
||||
} else {
|
||||
log.Printf("Ошибка чтения аудио: %v", err)
|
||||
}
|
||||
m.handleCrash(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
samplesCount := len(buffer) / 2
|
||||
samples := make([]int16, samplesCount)
|
||||
reader := bytes.NewReader(buffer)
|
||||
if err := binary.Read(reader, binary.LittleEndian, &samples); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var sum float64
|
||||
for _, sample := range samples {
|
||||
val := float64(sample)
|
||||
sum += val * val
|
||||
}
|
||||
|
||||
rms := math.Sqrt(sum / float64(samplesCount))
|
||||
level := int((rms / 32768.0) * 100 * 4)
|
||||
if level > 100 {
|
||||
level = 100
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.currentLevel = level
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// handleCrash пытается пересобрать пайп arecord после того, как микрофон
|
||||
// отвалился на физическом уровне, не роняя всё приложение.
|
||||
func (m *Monitor) handleCrash(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Микрофон отключился, повторная попытка через 5 секунд...")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.startCapture(ctx); err != nil {
|
||||
log.Printf("Не удалось пересобрать аудио-пайп: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// findUSBDevice выбирает ALSA-устройство по выводу arecord -l: сначала USB-аудио,
|
||||
// затем любое устройство захвата, иначе "default".
|
||||
func (m *Monitor) findUSBDevice() string {
|
||||
cmd := exec.Command("arecord", "-l")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Printf("Не удалось получить список устройств: %v", err)
|
||||
return "default"
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
// Ищем любое USB аудиоустройство
|
||||
lowerLine := strings.ToLower(line)
|
||||
if strings.Contains(lowerLine, "usb") &&
|
||||
(strings.Contains(lowerLine, "audio") || strings.Contains(lowerLine, "sound")) {
|
||||
|
||||
log.Printf("Найдено USB аудиоустройство: %s", line)
|
||||
|
||||
// Извлекаем номера карты и устройства
|
||||
re := regexp.MustCompile(`card (\d+):.*device (\d+):`)
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) == 3 {
|
||||
device := fmt.Sprintf("plughw:%s,%s", matches[1], matches[2])
|
||||
log.Printf("Используем устройство: %s", device)
|
||||
return device
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если не нашли USB, пробуем найти любое устройство захвата
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "device") && strings.Contains(line, "Capture") {
|
||||
re := regexp.MustCompile(`card (\d+):.*device (\d+):`)
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) == 3 {
|
||||
device := fmt.Sprintf("plughw:%s,%s", matches[1], matches[2])
|
||||
log.Printf("Используем первое найденное устройство: %s", device)
|
||||
return device
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Аудиоустройства не найдены, используем default")
|
||||
return "default"
|
||||
}
|
||||
|
||||
// Stop останавливает захват и дожидается завершения горутины чтения.
|
||||
func (m *Monitor) Stop() {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
m.wg.Wait()
|
||||
}
|
||||
42
internal/logger/config.go
Normal file
42
internal/logger/config.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Package logger реализует хранение данных GPIO: разбор байта, бинарные
|
||||
// почасовые логи с ротацией, человекочитаемые и событийные логи, watchdog
|
||||
// тишины и retention (автоочистку старых файлов). Пути хранения зависят от
|
||||
// режима работы (deb-пакет или разработка) и вычисляются в paths.go.
|
||||
package logger
|
||||
|
||||
import "time"
|
||||
|
||||
// Config содержит настройки для всех компонентов логирования
|
||||
type Config struct {
|
||||
// Retention настройки
|
||||
RetentionHours int
|
||||
RetentionMB int
|
||||
RetentionIntervalMin time.Duration
|
||||
|
||||
// Мониторинг тишины
|
||||
Silence5Min time.Duration
|
||||
Silence10Min time.Duration
|
||||
WatchdogIntervalMin time.Duration
|
||||
|
||||
// Анти-спам и интервалы
|
||||
AlertCooldownSec time.Duration
|
||||
HumanLogIntervalSec time.Duration
|
||||
|
||||
// Буфер
|
||||
BufferSize int
|
||||
}
|
||||
|
||||
// DefaultConfig возвращает конфигурацию по умолчанию
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
RetentionHours: 48,
|
||||
RetentionMB: 5000,
|
||||
RetentionIntervalMin: 15 * time.Minute,
|
||||
Silence5Min: 5 * time.Minute,
|
||||
Silence10Min: 10 * time.Minute,
|
||||
WatchdogIntervalMin: 1 * time.Minute,
|
||||
AlertCooldownSec: 10 * time.Second,
|
||||
HumanLogIntervalSec: 5 * time.Second,
|
||||
BufferSize: 10240,
|
||||
}
|
||||
}
|
||||
106
internal/logger/data_logger.go
Normal file
106
internal/logger/data_logger.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DataLogger пишет сэмплы в бинарный файл с буферизацией: буфер 64 КБ
|
||||
// сбрасывается раз в секунду либо при заполнении, после сброса — fsync.
|
||||
type DataLogger struct {
|
||||
file *os.File
|
||||
mu sync.Mutex
|
||||
buffer []byte
|
||||
bufferSize int
|
||||
flushTick *time.Ticker
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
// Sample — одно измерение: метка времени в микросекундах Unix и сырой байт GPIO.
|
||||
type Sample struct {
|
||||
Timestamp int64 // microseconds
|
||||
Value byte
|
||||
}
|
||||
|
||||
// NewDataLogger открывает файл basePath на дозапись и запускает фоновый
|
||||
// цикл сброса буфера. Именование файлов и смену часа обеспечивает RotatingLogger.
|
||||
func NewDataLogger(basePath string) (*DataLogger, error) {
|
||||
// путь будет формироваться через rotation
|
||||
f, err := os.OpenFile(basePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := &DataLogger{
|
||||
file: f,
|
||||
buffer: make([]byte, 0, 64*1024), // 64KB буфер
|
||||
bufferSize: 0,
|
||||
flushTick: time.NewTicker(1 * time.Second),
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
go d.flushLoop()
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Write добавляет сэмпл в буфер в формате 9 байт:
|
||||
// uint64 little-endian (микросекунды Unix) + 1 байт значения.
|
||||
func (d *DataLogger) Write(s Sample) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Формат: [timestamp uint64][value byte]
|
||||
tsBuf := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(tsBuf, uint64(s.Timestamp))
|
||||
|
||||
d.buffer = append(d.buffer, tsBuf...)
|
||||
d.buffer = append(d.buffer, s.Value)
|
||||
d.bufferSize += 9
|
||||
|
||||
// Если буфер переполнен - сбрасываем немедленно
|
||||
if d.bufferSize >= 64*1024 {
|
||||
d.flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataLogger) flush() {
|
||||
if d.bufferSize == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := d.file.Write(d.buffer[:d.bufferSize])
|
||||
if err != nil {
|
||||
// тут должен быть event
|
||||
return
|
||||
}
|
||||
|
||||
d.file.Sync() // важно для durability
|
||||
|
||||
d.buffer = d.buffer[:0]
|
||||
d.bufferSize = 0
|
||||
}
|
||||
|
||||
func (d *DataLogger) flushLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-d.flushTick.C:
|
||||
d.mu.Lock()
|
||||
d.flush()
|
||||
d.mu.Unlock()
|
||||
case <-d.closeCh:
|
||||
d.mu.Lock()
|
||||
d.flush()
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close останавливает цикл сброса, дописывает остаток буфера и закрывает файл.
|
||||
func (d *DataLogger) Close() error {
|
||||
close(d.closeCh)
|
||||
d.flushTick.Stop()
|
||||
return d.file.Close()
|
||||
}
|
||||
87
internal/logger/event_logger.go
Normal file
87
internal/logger/event_logger.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventLogger пишет системные события параллельно в два файла: events.log
|
||||
// (JSON-строки для машинной обработки) и events_human.log (текст; его читает
|
||||
// /api/log/events). Словарь событий — docs/data-formats.md.
|
||||
type EventLogger struct {
|
||||
file *os.File
|
||||
humanFile *os.File
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewEventLogger открывает оба файла событий на дозапись.
|
||||
func NewEventLogger() (*EventLogger, error) {
|
||||
// Получаем пути к файлам
|
||||
jsonPath, err := GetEventLogPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения пути для JSON логов: %w", err)
|
||||
}
|
||||
|
||||
humanPath, err := GetEventHumanLogPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения пути для Human логов: %w", err)
|
||||
}
|
||||
|
||||
// Открываем JSON файл
|
||||
f, err := os.OpenFile(jsonPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка открытия JSON лога: %w", err)
|
||||
}
|
||||
|
||||
// Открываем Human-readable файл
|
||||
humanF, err := os.OpenFile(humanPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("ошибка открытия Human лога: %w", err)
|
||||
}
|
||||
|
||||
return &EventLogger{
|
||||
file: f,
|
||||
humanFile: humanF,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Event записывает событие name в оба файла с текущим временем и sync.
|
||||
func (l *EventLogger) Event(name string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// JSON формат (для машин)
|
||||
ev := struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Time string `json:"time"`
|
||||
Event string `json:"event"`
|
||||
}{
|
||||
Timestamp: now.Unix(),
|
||||
Time: now.Format("2006-01-02 15:04:05"),
|
||||
Event: name,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(ev)
|
||||
data = append(data, '\n')
|
||||
l.file.Write(data)
|
||||
|
||||
// Human-readable формат
|
||||
humanLine := fmt.Sprintf("[%s] EVENT: %s\n", now.Format("2006-01-02 15:04:05.000"), name)
|
||||
l.humanFile.WriteString(humanLine)
|
||||
|
||||
l.file.Sync()
|
||||
l.humanFile.Sync()
|
||||
}
|
||||
|
||||
// Close закрывает оба файла событий.
|
||||
func (l *EventLogger) Close() error {
|
||||
l.file.Close()
|
||||
l.humanFile.Close()
|
||||
return nil
|
||||
}
|
||||
42
internal/logger/human_logger.go
Normal file
42
internal/logger/human_logger.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HumanLogger пишет человекочитаемый лог GPIO (gpio_human.log) с немедленным
|
||||
// sync после каждой строки. Частоту записей регулирует вызывающий код (PipeReader).
|
||||
type HumanLogger struct {
|
||||
file *os.File
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewHumanLogger открывает gpio_human.log на дозапись.
|
||||
func NewHumanLogger() (*HumanLogger, error) {
|
||||
path, err := GetGPIOLogPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &HumanLogger{file: f}, nil
|
||||
}
|
||||
|
||||
// Write дописывает готовую строку в лог.
|
||||
func (l *HumanLogger) Write(data string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.file.WriteString(data)
|
||||
l.file.Sync()
|
||||
}
|
||||
|
||||
// Close закрывает файл лога.
|
||||
func (l *HumanLogger) Close() error {
|
||||
return l.file.Close()
|
||||
}
|
||||
65
internal/logger/monitor.go
Normal file
65
internal/logger/monitor.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Monitor — watchdog потока данных: PipeReader отмечает каждую запись через
|
||||
// RecordWrite, фоновый цикл сравнивает длительность тишины с порогами и пишет
|
||||
// события ТИШИНА_5МИН/ТИШИНА_10МИН.
|
||||
type Monitor struct {
|
||||
eventLogger *EventLogger
|
||||
lastWrite time.Time
|
||||
mu sync.RWMutex
|
||||
silence5Min time.Duration
|
||||
silence10Min time.Duration
|
||||
watchdogInterval time.Duration
|
||||
}
|
||||
|
||||
// NewMonitor создаёт watchdog с порогами из config и сразу запускает фоновый цикл.
|
||||
func NewMonitor(eventLogger *EventLogger, config *Config) *Monitor {
|
||||
m := &Monitor{
|
||||
eventLogger: eventLogger,
|
||||
lastWrite: time.Now(),
|
||||
silence5Min: config.Silence5Min,
|
||||
silence10Min: config.Silence10Min,
|
||||
watchdogInterval: config.WatchdogIntervalMin,
|
||||
}
|
||||
go m.watchdogLoop()
|
||||
return m
|
||||
}
|
||||
|
||||
// SetSilenceThresholds позволяет изменить пороги тишины после создания
|
||||
func (m *Monitor) SetSilenceThresholds(silence5Min, silence10Min time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.silence5Min = silence5Min
|
||||
m.silence10Min = silence10Min
|
||||
}
|
||||
|
||||
// RecordWrite отмечает факт приёма данных (сбрасывает отсчёт тишины).
|
||||
func (m *Monitor) RecordWrite() {
|
||||
m.mu.Lock()
|
||||
m.lastWrite = time.Now()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Monitor) watchdogLoop() {
|
||||
ticker := time.NewTicker(m.watchdogInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
m.mu.RLock()
|
||||
silence := time.Since(m.lastWrite)
|
||||
silence5Min := m.silence5Min
|
||||
silence10Min := m.silence10Min
|
||||
m.mu.RUnlock()
|
||||
|
||||
if silence > silence10Min {
|
||||
m.eventLogger.Event("ТИШИНА_10МИН")
|
||||
} else if silence > silence5Min {
|
||||
m.eventLogger.Event("ТИШИНА_5МИН")
|
||||
}
|
||||
}
|
||||
}
|
||||
42
internal/logger/parser.go
Normal file
42
internal/logger/parser.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package logger
|
||||
|
||||
// GPIOData — разобранный байт GPIO-шины: количество обнаружений (биты 0–5),
|
||||
// сила сигнала (биты 6–7) и признак превышения порога тревоги.
|
||||
type GPIOData struct {
|
||||
RawValue byte // сырое значение
|
||||
Count byte // количество обнаружений (биты 0-5)
|
||||
Strength byte // сила сигнала (биты 6-7)
|
||||
IsHighCount bool // превышает ли количество 10
|
||||
}
|
||||
|
||||
// ParseGPIO парсит сырой байт на составляющие
|
||||
func ParseGPIO(raw byte) GPIOData {
|
||||
// Биты 0-5: количество (0-63)
|
||||
count := raw & 0x3F // маска 00111111
|
||||
|
||||
// Биты 6-7: сила (0-3)
|
||||
strength := (raw >> 6) & 0x03 // сдвиг на 6 бит, маска 00000011
|
||||
|
||||
return GPIOData{
|
||||
RawValue: raw,
|
||||
Count: count,
|
||||
Strength: strength,
|
||||
IsHighCount: count > 10,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStrengthName возвращает текстовое описание силы
|
||||
func GetStrengthName(strength byte) string {
|
||||
switch strength {
|
||||
case 0:
|
||||
return "Слабый"
|
||||
case 1:
|
||||
return "Средний"
|
||||
case 2:
|
||||
return "Сильный"
|
||||
case 3:
|
||||
return "Максимальный"
|
||||
default:
|
||||
return "Неизвестно"
|
||||
}
|
||||
}
|
||||
126
internal/logger/paths.go
Normal file
126
internal/logger/paths.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GetDataDir возвращает стандартную директорию для данных приложения
|
||||
func GetDataDir() (string, error) {
|
||||
// Проверяем, установлен ли пакет (существует ли /opt/gpio-monitoring)
|
||||
if _, err := os.Stat("/opt/gpio-monitoring"); err == nil {
|
||||
// Используем стандартную директорию для deb пакета
|
||||
dataDir := "/var/lib/gpio-monitoring"
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dataDir, nil
|
||||
}
|
||||
|
||||
// Для разработки - используем XDG Base Directory Specification
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// ~/.local/share/gpio-monitoring
|
||||
dataDir := filepath.Join(homeDir, ".local", "share", "gpio-monitoring")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return dataDir, nil
|
||||
}
|
||||
|
||||
// GetLogsDir возвращает директорию для логов
|
||||
func GetLogsDir() (string, error) {
|
||||
// Проверяем, установлен ли пакет
|
||||
if _, err := os.Stat("/opt/gpio-monitoring"); err == nil {
|
||||
logsDir := "/var/log/gpio-monitoring"
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return logsDir, nil
|
||||
}
|
||||
|
||||
// Для разработки
|
||||
dataDir, err := GetDataDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
logsDir := filepath.Join(dataDir, "logs")
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return logsDir, nil
|
||||
}
|
||||
|
||||
// GetDataLogsDir возвращает директорию для бинарных данных
|
||||
func GetDataLogsDir() (string, error) {
|
||||
logsDir, err := GetLogsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(logsDir, "data")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return dataDir, nil
|
||||
}
|
||||
|
||||
// GetEventLogPath возвращает путь к файлу событий
|
||||
func GetEventLogPath() (string, error) {
|
||||
logsDir, err := GetLogsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(logsDir, "events.log"), nil
|
||||
}
|
||||
|
||||
// GetEventHumanLogPath возвращает путь к человеко-читаемому файлу событий
|
||||
func GetEventHumanLogPath() (string, error) {
|
||||
logsDir, err := GetLogsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(logsDir, "events_human.log"), nil
|
||||
}
|
||||
|
||||
// GetGPIOLogPath возвращает путь к человеко-читаемому GPIO логу
|
||||
func GetGPIOLogPath() (string, error) {
|
||||
logsDir, err := GetLogsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(logsDir, "gpio_human.log"), nil
|
||||
}
|
||||
|
||||
// GetWebDir возвращает директорию с веб-файлами
|
||||
func GetWebDir() string {
|
||||
// Проверяем стандартные пути
|
||||
possiblePaths := []string{
|
||||
"/opt/gpio-monitoring/web",
|
||||
"/usr/share/gpio-monitoring/web",
|
||||
"./cmd/server/web",
|
||||
"../cmd/server/web",
|
||||
}
|
||||
|
||||
for _, path := range possiblePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
// Проверяем, что есть index.html
|
||||
if _, err := os.Stat(filepath.Join(path, "index.html")); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback для разработки
|
||||
return "./cmd/server/web"
|
||||
}
|
||||
252
internal/logger/retention.go
Normal file
252
internal/logger/retention.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// minKeepFiles — сколько самых свежих файлов всегда сохраняем, даже если
|
||||
// возраст или размер говорят удалить. Защита от полного стирания директории.
|
||||
const minKeepFiles = 2
|
||||
|
||||
// Retention — фоновая FIFO-очистка бинарных логов по двум независимым лимитам:
|
||||
// возрасту (maxAgeHours) и суммарному размеру (maxSizeBytes). Любой лимит
|
||||
// отключается нулём; минимум minKeepFiles свежих файлов сохраняется всегда.
|
||||
type Retention struct {
|
||||
maxAgeHours int
|
||||
maxSizeBytes int64
|
||||
eventLogger *EventLogger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewRetention создаёт очистку с лимитами; запуск — Start или StartWithInterval.
|
||||
func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Retention {
|
||||
return &Retention{
|
||||
maxAgeHours: maxAgeHours,
|
||||
maxSizeBytes: int64(maxSizeMB) * 1024 * 1024,
|
||||
eventLogger: eventLogger,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start запускает очистку со стандартным интервалом 15 минут.
|
||||
func (r *Retention) Start() {
|
||||
r.StartWithInterval(15 * time.Minute)
|
||||
}
|
||||
|
||||
// StartWithInterval запускает фоновый цикл очистки с заданным интервалом;
|
||||
// первая очистка выполняется через 1 минуту после запуска.
|
||||
func (r *Retention) StartWithInterval(interval time.Duration) {
|
||||
// Запускаем проверку с указанным интервалом
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
r.Cleanup()
|
||||
case <-r.stopCh:
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Первая очистка через 1 минуту после запуска
|
||||
time.AfterFunc(1*time.Minute, func() {
|
||||
r.Cleanup()
|
||||
})
|
||||
}
|
||||
|
||||
// Stop останавливает фоновый цикл очистки.
|
||||
func (r *Retention) Stop() {
|
||||
close(r.stopCh)
|
||||
}
|
||||
|
||||
// Cleanup выполняет один проход очистки: сначала по возрасту, затем по размеру.
|
||||
// Удаления фиксируются суммарным событием ОЧИСТКА_ЛОГОВ_УДАЛЕНО_N_ФАЙЛОВ_M_MB.
|
||||
func (r *Retention) Cleanup() {
|
||||
// Если оба лимита отключены, ничего не делаем
|
||||
if r.maxAgeHours <= 0 && r.maxSizeBytes <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем директорию с данными
|
||||
dataDir, err := GetDataLogsDir()
|
||||
if err != nil {
|
||||
if r.eventLogger != nil {
|
||||
r.eventLogger.Event("ОШИБКА_ПОЛУЧЕНИЯ_ДИРЕКТОРИИ")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Удаляем старые файлы (если включено)
|
||||
deletedByAge := 0
|
||||
var deletedByAgeBytes int64
|
||||
if r.maxAgeHours > 0 {
|
||||
deletedByAge, deletedByAgeBytes = r.cleanByAge(dataDir)
|
||||
}
|
||||
|
||||
// 2. Проверяем общий размер и удаляем самые старые если превышен лимит (если включено)
|
||||
deletedBySize := 0
|
||||
var deletedBySizeBytes int64
|
||||
if r.maxSizeBytes > 0 {
|
||||
deletedBySize, deletedBySizeBytes = r.cleanBySize(dataDir)
|
||||
}
|
||||
|
||||
totalDeleted := deletedByAge + deletedBySize
|
||||
totalDeletedBytes := deletedByAgeBytes + deletedBySizeBytes
|
||||
|
||||
// Логируем ТОЛЬКО если были удаления
|
||||
if totalDeleted > 0 {
|
||||
// Логируем суммарное событие
|
||||
if r.eventLogger != nil {
|
||||
eventMsg := fmt.Sprintf("ОЧИСТКА_ЛОГОВ_УДАЛЕНО_%d_ФАЙЛОВ_%d_MB",
|
||||
totalDeleted, totalDeletedBytes/(1024*1024))
|
||||
r.eventLogger.Event(eventMsg)
|
||||
}
|
||||
|
||||
// Пишем в консоль для отладки
|
||||
log.Printf("Очистка логов: удалено %d файлов (%.2f MB) [по возрасту: %d, по размеру: %d]",
|
||||
totalDeleted, float64(totalDeletedBytes)/(1024*1024), deletedByAge, deletedBySize)
|
||||
}
|
||||
}
|
||||
|
||||
// fileInfo описывает один лог-файл: путь, время из имени (или ModTime как
|
||||
// запасной вариант), реальное время последней записи и размер.
|
||||
type fileInfo struct {
|
||||
path string
|
||||
time time.Time
|
||||
modTime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// collectFiles возвращает все gpio-*.bin, отсортированные от старых к новым
|
||||
// (FIFO-порядок), и их суммарный размер в байтах.
|
||||
func (r *Retention) collectFiles(dataDir string) ([]fileInfo, int64) {
|
||||
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
var fileList []fileInfo
|
||||
var totalSize int64
|
||||
|
||||
for _, file := range files {
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Время из имени файла; если распарсить не удалось — берём ModTime.
|
||||
fileTime := r.parseFilenameTime(file)
|
||||
if fileTime.IsZero() {
|
||||
fileTime = info.ModTime()
|
||||
}
|
||||
|
||||
fileList = append(fileList, fileInfo{
|
||||
path: file,
|
||||
time: fileTime,
|
||||
modTime: info.ModTime(),
|
||||
size: info.Size(),
|
||||
})
|
||||
totalSize += info.Size()
|
||||
}
|
||||
|
||||
// Сортируем по времени (старые первые) — FIFO.
|
||||
sort.Slice(fileList, func(i, j int) bool {
|
||||
return fileList[i].time.Before(fileList[j].time)
|
||||
})
|
||||
|
||||
return fileList, totalSize
|
||||
}
|
||||
|
||||
// cleanByAge удаляет старые файлы по FIFO: с самого старого края, пока файл
|
||||
// старше cutoff И по имени, И по ModTime, и пока не упрёмся в minKeepFiles.
|
||||
// Возвращает количество удалённых файлов и их общий размер в байтах.
|
||||
func (r *Retention) cleanByAge(dataDir string) (int, int64) {
|
||||
cutoff := time.Now().Add(-time.Duration(r.maxAgeHours) * time.Hour)
|
||||
|
||||
files, _ := r.collectFiles(dataDir)
|
||||
|
||||
deleted := 0
|
||||
var deletedBytes int64
|
||||
|
||||
for len(files) > minKeepFiles {
|
||||
f := files[0]
|
||||
|
||||
// Возраст — это цель, а не гильотина. Файл удаляем только если он старше
|
||||
// cutoff и по имени, и по реальному времени записи. Это защищает от
|
||||
// скачка часов (после NTP на устройстве без RTC): свежий по ModTime
|
||||
// файл со «старым» именем не будет снесён.
|
||||
if !f.time.Before(cutoff) || !f.modTime.Before(cutoff) {
|
||||
// Список отсортирован от старых к новым — дальше только моложе.
|
||||
break
|
||||
}
|
||||
|
||||
if err := os.Remove(f.path); err != nil {
|
||||
break
|
||||
}
|
||||
deleted++
|
||||
deletedBytes += f.size
|
||||
files = files[1:]
|
||||
}
|
||||
|
||||
return deleted, deletedBytes
|
||||
}
|
||||
|
||||
// cleanBySize удаляет самые старые файлы, пока суммарный размер превышает лимит,
|
||||
// сохраняя как минимум minKeepFiles файлов.
|
||||
// Возвращает количество удалённых файлов и их общий размер в байтах.
|
||||
func (r *Retention) cleanBySize(dataDir string) (int, int64) {
|
||||
fileList, totalSize := r.collectFiles(dataDir)
|
||||
|
||||
deleted := 0
|
||||
var deletedBytes int64
|
||||
|
||||
for totalSize > r.maxSizeBytes && len(fileList) > minKeepFiles {
|
||||
// Удаляем самый старый файл.
|
||||
oldest := fileList[0]
|
||||
if err := os.Remove(oldest.path); err != nil {
|
||||
break
|
||||
}
|
||||
totalSize -= oldest.size
|
||||
deletedBytes += oldest.size
|
||||
deleted++
|
||||
fileList = fileList[1:]
|
||||
}
|
||||
|
||||
return deleted, deletedBytes
|
||||
}
|
||||
|
||||
func (r *Retention) parseFilenameTime(filename string) time.Time {
|
||||
base := filepath.Base(filename)
|
||||
// Формат: gpio-2026-06-09-11.bin
|
||||
parts := strings.Split(strings.TrimSuffix(base, ".bin"), "-")
|
||||
if len(parts) != 5 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
year, err := time.Parse("2006", parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
month, err := time.Parse("01", parts[2])
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
day, err := time.Parse("02", parts[3])
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
hour, err := time.Parse("15", parts[4])
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return time.Date(year.Year(), month.Month(), day.Day(), hour.Hour(), 0, 0, 0, time.Local)
|
||||
}
|
||||
192
internal/logger/retention_test.go
Normal file
192
internal/logger/retention_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makeLogFile создаёт gpio-файл с заданным именем и выставляет ему ModTime.
|
||||
func makeLogFile(t *testing.T, dir, name string, modTime time.Time) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// nameForHour формирует имя файла в формате rotation.go для момента t.
|
||||
func nameForHour(t time.Time) string {
|
||||
return fmt.Sprintf("gpio-%04d-%02d-%02d-%02d.bin",
|
||||
t.Year(), t.Month(), t.Day(), t.Hour())
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// TestCleanByAge_RollingWindow: старые файлы удаляются, но свежие в окне 72ч и
|
||||
// минимум minKeepFiles всегда сохраняются.
|
||||
func TestCleanByAge_RollingWindow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
// Имена файлов хранят время с точностью до часа, поэтому держим обе группы
|
||||
// с запасом от границы 72ч, чтобы округление до часа не решало исход теста.
|
||||
var oldFiles, freshFiles []string
|
||||
for hoursAgo := 200; hoursAgo >= 76; hoursAgo-- { // старше 72ч с запасом 4ч
|
||||
ft := now.Add(-time.Duration(hoursAgo) * time.Hour)
|
||||
oldFiles = append(oldFiles, makeLogFile(t, dir, nameForHour(ft), ft))
|
||||
}
|
||||
for hoursAgo := 68; hoursAgo >= 1; hoursAgo-- { // свежее 72ч с запасом 4ч
|
||||
ft := now.Add(-time.Duration(hoursAgo) * time.Hour)
|
||||
freshFiles = append(freshFiles, makeLogFile(t, dir, nameForHour(ft), ft))
|
||||
}
|
||||
|
||||
r := NewRetention(72, 0, nil)
|
||||
deleted, _ := r.cleanByAge(dir)
|
||||
|
||||
if deleted != len(oldFiles) {
|
||||
t.Errorf("удалено %d, ожидалось %d старых файлов", deleted, len(oldFiles))
|
||||
}
|
||||
for _, p := range oldFiles {
|
||||
if exists(p) {
|
||||
t.Errorf("старый файл не удалён: %s", filepath.Base(p))
|
||||
}
|
||||
}
|
||||
for _, p := range freshFiles {
|
||||
if !exists(p) {
|
||||
t.Errorf("свежий файл ошибочно удалён: %s", filepath.Base(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanByAge_MinKeepFloor: даже когда ВСЕ файлы старше cutoff, minKeepFiles
|
||||
// самых свежих остаются — директория не стирается целиком.
|
||||
func TestCleanByAge_MinKeepFloor(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
// 5 файлов, все старше 72ч.
|
||||
var paths []string
|
||||
for i := 0; i < 5; i++ {
|
||||
ft := now.Add(-time.Duration(200-i) * time.Hour)
|
||||
paths = append(paths, makeLogFile(t, dir, nameForHour(ft), ft))
|
||||
}
|
||||
|
||||
r := NewRetention(72, 0, nil)
|
||||
deleted, _ := r.cleanByAge(dir)
|
||||
|
||||
remaining := 5 - deleted
|
||||
if remaining != minKeepFiles {
|
||||
t.Errorf("осталось %d файлов, ожидалось minKeepFiles=%d", remaining, minKeepFiles)
|
||||
}
|
||||
// Должны выжить именно самые свежие (последние два по индексу).
|
||||
for _, p := range paths[len(paths)-minKeepFiles:] {
|
||||
if !exists(p) {
|
||||
t.Errorf("свежайший файл должен был сохраниться: %s", filepath.Base(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanByAge_ClockJumpGuard: имя файла «старое», но ModTime свежий
|
||||
// (сценарий скачка часов после NTP). Файл должен остаться.
|
||||
func TestCleanByAge_ClockJumpGuard(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
// Достаточно файлов, чтобы floor не мешал проверке guard.
|
||||
// Настоящие старые файлы (и имя, и ModTime старые).
|
||||
oldTime := now.Add(-200 * time.Hour)
|
||||
for i := 0; i < 4; i++ {
|
||||
ft := oldTime.Add(time.Duration(i) * time.Hour)
|
||||
makeLogFile(t, dir, nameForHour(ft), ft)
|
||||
}
|
||||
// Файл со «старым» именем, но записанный только что.
|
||||
staleName := nameForHour(now.Add(-300 * time.Hour))
|
||||
guarded := makeLogFile(t, dir, staleName, now)
|
||||
|
||||
r := NewRetention(72, 0, nil)
|
||||
r.cleanByAge(dir)
|
||||
|
||||
if !exists(guarded) {
|
||||
t.Errorf("файл со свежим ModTime не должен удаляться при скачке часов: %s", staleName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanByAge_ParseFailureFallsBackToModTime: нераспознаваемое имя не должно
|
||||
// приводить к удалению свежего файла (fallback на ModTime).
|
||||
func TestCleanByAge_ParseFailureFallsBackToModTime(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
// Контекст из нормальных старых файлов, чтобы был выбор для удаления.
|
||||
for i := 0; i < 4; i++ {
|
||||
ft := now.Add(-time.Duration(200-i) * time.Hour)
|
||||
makeLogFile(t, dir, nameForHour(ft), ft)
|
||||
}
|
||||
// Битое имя, но свежий ModTime.
|
||||
garbage := makeLogFile(t, dir, "gpio-garbage.bin", now)
|
||||
|
||||
r := NewRetention(72, 0, nil)
|
||||
r.cleanByAge(dir)
|
||||
|
||||
if !exists(garbage) {
|
||||
t.Error("файл с нераспознаваемым именем, но свежим ModTime, не должен удаляться")
|
||||
}
|
||||
|
||||
// А вот битое имя со СТАРЫМ ModTime — законно удаляется как старое.
|
||||
dir2 := t.TempDir()
|
||||
for i := 0; i < 4; i++ {
|
||||
ft := now.Add(-time.Duration(20-i) * time.Hour) // свежие, чтобы не удалялись
|
||||
makeLogFile(t, dir2, nameForHour(ft), ft)
|
||||
}
|
||||
oldGarbage := makeLogFile(t, dir2, "gpio-garbage.bin", now.Add(-200*time.Hour))
|
||||
r.cleanByAge(dir2)
|
||||
if exists(oldGarbage) {
|
||||
t.Error("битое имя со старым ModTime должно удаляться как старый файл")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanBySize_KeepsFloor: очистка по размеру не опускается ниже minKeepFiles.
|
||||
func TestCleanBySize_KeepsFloor(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Now()
|
||||
for i := 0; i < 6; i++ {
|
||||
ft := now.Add(-time.Duration(6-i) * time.Hour)
|
||||
makeLogFile(t, dir, nameForHour(ft), ft)
|
||||
}
|
||||
|
||||
// Крошечный лимит: без floor удалились бы все.
|
||||
r := &Retention{maxAgeHours: 0, maxSizeBytes: 1, eventLogger: nil, stopCh: make(chan struct{})}
|
||||
r.cleanBySize(dir)
|
||||
|
||||
files, _ := r.collectFiles(dir)
|
||||
if len(files) != minKeepFiles {
|
||||
t.Errorf("после cleanBySize осталось %d, ожидалось minKeepFiles=%d", len(files), minKeepFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFilenameTime: корректное имя парсится, битое даёт нулевое время.
|
||||
func TestParseFilenameTime(t *testing.T) {
|
||||
r := NewRetention(72, 0, nil)
|
||||
|
||||
got := r.parseFilenameTime("gpio-2026-06-09-11.bin")
|
||||
want := time.Date(2026, 6, 9, 11, 0, 0, 0, time.Local)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("parseFilenameTime = %v, ожидалось %v", got, want)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"gpio-garbage.bin", "gpio-2026-XX-09-11.bin", "gpio-2026-06-09.bin"} {
|
||||
if !r.parseFilenameTime(bad).IsZero() {
|
||||
t.Errorf("для %q ожидалось нулевое время", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
internal/logger/rotation.go
Normal file
119
internal/logger/rotation.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RotatingLogger — обёртка над DataLogger с почасовой ротацией: держит файл
|
||||
// текущего часа (gpio-YYYY-MM-DD-HH.bin) и по таймеру переоткрывает его при
|
||||
// смене часа, фиксируя событие ПЛАНОВАЯ_РОТАЦИЯ.
|
||||
type RotatingLogger struct {
|
||||
dataLogger *DataLogger
|
||||
currentHour int
|
||||
baseDir string
|
||||
mu sync.Mutex
|
||||
eventLogger *EventLogger
|
||||
checkInterval time.Duration
|
||||
}
|
||||
|
||||
// NewRotatingLogger открывает файл текущего часа в каталоге бинарных данных
|
||||
// и запускает цикл проверки ротации (checkInterval; 0 — раз в минуту).
|
||||
func NewRotatingLogger(eventLogger *EventLogger, checkInterval time.Duration) (*RotatingLogger, error) {
|
||||
// Получаем директорию для бинарных данных
|
||||
dataDir, err := GetDataLogsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Интервал проверки по умолчанию
|
||||
if checkInterval == 0 {
|
||||
checkInterval = 1 * time.Minute
|
||||
}
|
||||
|
||||
r := &RotatingLogger{
|
||||
baseDir: dataDir,
|
||||
eventLogger: eventLogger,
|
||||
checkInterval: checkInterval,
|
||||
}
|
||||
|
||||
if err := r.rotate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go r.rotationLoop()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *RotatingLogger) getFilename() string {
|
||||
now := time.Now()
|
||||
return filepath.Join(r.baseDir, fmt.Sprintf("gpio-%04d-%02d-%02d-%02d.bin",
|
||||
now.Year(), now.Month(), now.Day(), now.Hour()))
|
||||
}
|
||||
|
||||
func (r *RotatingLogger) rotate() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
newHour := now.Hour()
|
||||
|
||||
// Если уже правильный час и логгер существует - ок
|
||||
if r.dataLogger != nil && r.currentHour == newHour {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Закрываем старый
|
||||
if r.dataLogger != nil {
|
||||
r.dataLogger.Close()
|
||||
}
|
||||
|
||||
// Открываем новый
|
||||
filename := r.getFilename()
|
||||
dataLogger, err := NewDataLogger(filename)
|
||||
if err != nil {
|
||||
if r.eventLogger != nil {
|
||||
r.eventLogger.Event("ПЛАНОВАЯ_РОТАЦИЯ_НЕУДАЧНА")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
r.dataLogger = dataLogger
|
||||
r.currentHour = newHour
|
||||
if r.eventLogger != nil {
|
||||
r.eventLogger.Event("ПЛАНОВАЯ_РОТАЦИЯ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write передаёт сэмпл текущему DataLogger.
|
||||
func (r *RotatingLogger) Write(s Sample) {
|
||||
r.mu.Lock()
|
||||
logger := r.dataLogger
|
||||
r.mu.Unlock()
|
||||
|
||||
if logger != nil {
|
||||
logger.Write(s)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RotatingLogger) rotationLoop() {
|
||||
ticker := time.NewTicker(r.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
r.rotate()
|
||||
}
|
||||
}
|
||||
|
||||
// Close закрывает текущий DataLogger (с финальным сбросом буфера).
|
||||
func (r *RotatingLogger) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.dataLogger != nil {
|
||||
return r.dataLogger.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
228
internal/pipe/reader.go
Normal file
228
internal/pipe/reader.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Package pipe читает поток байт GPIO из именованного канала (FIFO) и раздаёт
|
||||
// их потребителям: кольцевому буферу, бинарному и текстовым логгерам, watchdog.
|
||||
package pipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gpio-monitor/internal/adapter"
|
||||
"gpio-monitor/internal/logger"
|
||||
)
|
||||
|
||||
// PipeReader — единственный «производитель» данных в системе: следит за FIFO,
|
||||
// переподключается при обрывах и раздаёт каждый принятый байт потребителям.
|
||||
// Ведёт машину состояний пайпа (unknown/found/not_found/error/disconnected)
|
||||
// и пишет события смены состояния без дублей.
|
||||
type PipeReader struct {
|
||||
buf *adapter.RingBuffer
|
||||
dataLogger *logger.RotatingLogger
|
||||
humanLogger *logger.HumanLogger
|
||||
monitor *logger.Monitor
|
||||
eventLog *logger.EventLogger
|
||||
|
||||
// Для дедупликации логов
|
||||
lastData logger.GPIOData
|
||||
lastLogTime time.Time
|
||||
lastAlertTime map[byte]time.Time
|
||||
|
||||
// Настройки
|
||||
alertCooldownSec time.Duration
|
||||
humanLogIntervalSec time.Duration
|
||||
|
||||
// Флаги состояния
|
||||
pipeState string // "unknown", "found", "not_found"
|
||||
lastLoggedState string // для отслеживания изменений
|
||||
}
|
||||
|
||||
// NewPipeReader создаёт читателя с настройками анти-спама из config.
|
||||
// Любой из потребителей (dataLogger, humanLogger, monitor, eventLog) может быть nil.
|
||||
func NewPipeReader(
|
||||
buf *adapter.RingBuffer,
|
||||
dataLogger *logger.RotatingLogger,
|
||||
humanLogger *logger.HumanLogger,
|
||||
monitor *logger.Monitor,
|
||||
eventLog *logger.EventLogger,
|
||||
config *logger.Config,
|
||||
) *PipeReader {
|
||||
return &PipeReader{
|
||||
buf: buf,
|
||||
dataLogger: dataLogger,
|
||||
humanLogger: humanLogger,
|
||||
monitor: monitor,
|
||||
eventLog: eventLog,
|
||||
lastAlertTime: make(map[byte]time.Time),
|
||||
alertCooldownSec: config.AlertCooldownSec,
|
||||
humanLogIntervalSec: config.HumanLogIntervalSec,
|
||||
pipeState: "unknown",
|
||||
lastLoggedState: "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func (pr *PipeReader) logStateChange(newState string, eventName string) {
|
||||
if newState != pr.lastLoggedState {
|
||||
if pr.eventLog != nil {
|
||||
pr.eventLog.Event(eventName)
|
||||
}
|
||||
pr.lastLoggedState = newState
|
||||
}
|
||||
}
|
||||
|
||||
// Start запускает чтение pipePath в отдельной горутине и сразу возвращается.
|
||||
// Если FIFO отсутствует — ждёт его появления (проверка каждые 2 с); при обрыве
|
||||
// чтения переоткрывает файл через 1 с. Останавливается по отмене ctx.
|
||||
func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
|
||||
go func() {
|
||||
buffer := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if _, err := os.Stat(pipePath); os.IsNotExist(err) {
|
||||
// Логируем ТОЛЬКО при смене состояния
|
||||
if pr.pipeState != "not_found" {
|
||||
pr.pipeState = "not_found"
|
||||
pr.logStateChange("not_found", "PIPE_НЕ_НАЙДЕН")
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(pipePath, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
if pr.pipeState != "error" {
|
||||
pr.pipeState = "error"
|
||||
pr.logStateChange("error", "ОШИБКА_ОТКРЫТИЯ_PIPE")
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Pipe успешно открыт
|
||||
if pr.pipeState != "found" {
|
||||
pr.pipeState = "found"
|
||||
pr.logStateChange("found", "PIPE_ПОДКЛЮЧЕН")
|
||||
}
|
||||
|
||||
for {
|
||||
n, err := f.Read(buffer)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
// Отключаемся только если были подключены
|
||||
if pr.pipeState == "found" {
|
||||
pr.pipeState = "disconnected"
|
||||
pr.logStateChange("disconnected", "PIPE_ОТКЛЮЧЕН")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Если были в состоянии ошибки/отключения, восстанавливаемся
|
||||
if pr.pipeState != "found" {
|
||||
pr.pipeState = "found"
|
||||
pr.logStateChange("found", "PIPE_ПОДКЛЮЧЕН")
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
rawByte := buffer[i]
|
||||
now := time.Now()
|
||||
|
||||
// Парсим данные
|
||||
data := logger.ParseGPIO(rawByte)
|
||||
|
||||
// 1. В RAM для UI (сырое значение)
|
||||
pr.buf.Write(rawByte)
|
||||
|
||||
// 2. На диск для архива (бинарный)
|
||||
if pr.dataLogger != nil {
|
||||
pr.dataLogger.Write(logger.Sample{
|
||||
Timestamp: now.UnixMicro(),
|
||||
Value: rawByte,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Human-readable лог (при изменении состояния или раз в N секунд)
|
||||
if pr.humanLogger != nil {
|
||||
shouldLog := false
|
||||
|
||||
// Логируем если изменилось количество или сила
|
||||
if data.Count != pr.lastData.Count || data.Strength != pr.lastData.Strength {
|
||||
shouldLog = true
|
||||
}
|
||||
|
||||
// Или если прошло больше N секунд с последнего лога
|
||||
if now.Sub(pr.lastLogTime) >= pr.humanLogIntervalSec {
|
||||
shouldLog = true
|
||||
}
|
||||
|
||||
if shouldLog {
|
||||
strengthName := logger.GetStrengthName(data.Strength)
|
||||
humanLine := fmt.Sprintf(
|
||||
"[%s] Обнаружение: %d объектов | Сила: %s (уровень %d) | Сырое: 0x%02X (%d)\n",
|
||||
now.Format("2006-01-02 15:04:05.000"),
|
||||
data.Count,
|
||||
strengthName,
|
||||
data.Strength,
|
||||
rawByte,
|
||||
rawByte,
|
||||
)
|
||||
pr.humanLogger.Write(humanLine)
|
||||
pr.lastData = data
|
||||
pr.lastLogTime = now
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Алерт если количество обнаружений > 10
|
||||
if data.IsHighCount {
|
||||
pr.handleAlert(data, now)
|
||||
}
|
||||
|
||||
// 5. Обновляем watchdog
|
||||
if pr.monitor != nil {
|
||||
pr.monitor.RecordWrite()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// handleAlert пишет алерт превышения (count > 10) в event- и human-логи,
|
||||
// не чаще одного раза в alertCooldownSec для одинакового значения count.
|
||||
func (pr *PipeReader) handleAlert(data logger.GPIOData, timestamp time.Time) {
|
||||
// Anti-spam: не чаще 1 алерта в N секунд для одинакового количества
|
||||
key := data.Count
|
||||
if last, exists := pr.lastAlertTime[key]; exists {
|
||||
if timestamp.Sub(last) < pr.alertCooldownSec {
|
||||
return
|
||||
}
|
||||
}
|
||||
pr.lastAlertTime[key] = timestamp
|
||||
|
||||
strengthName := logger.GetStrengthName(data.Strength)
|
||||
|
||||
// 1. В event log (JSON)
|
||||
if pr.eventLog != nil {
|
||||
pr.eventLog.Event(fmt.Sprintf("ОБНАРУЖЕНО_%d_ОБЪЕКТОВ_СИЛА_%d", data.Count, data.Strength))
|
||||
}
|
||||
|
||||
// 2. В human-readable лог с предупреждением
|
||||
if pr.humanLogger != nil {
|
||||
alertLine := fmt.Sprintf(
|
||||
"[%s] ВНИМАНИЕ: Обнаружено превышение! %d объектов | Сила: %s (уровень %d)\n",
|
||||
timestamp.Format("2006-01-02 15:04:05.000"),
|
||||
data.Count,
|
||||
strengthName,
|
||||
data.Strength,
|
||||
)
|
||||
pr.humanLogger.Write(alertLine)
|
||||
}
|
||||
}
|
||||
1
scripts/array.txt
Normal file
1
scripts/array.txt
Normal file
@@ -0,0 +1 @@
|
||||
[0xE0, 0x01, 0x4C, 0x88]
|
||||
15
scripts/emulatohackrf.sh
Normal file
15
scripts/emulatohackrf.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Эмулятор GPIO -> stdout (1 мс на байт)
|
||||
python3 /home/user/temp/golang/scripts/emulator.py \
|
||||
array.txt \
|
||||
- \
|
||||
0.001 \
|
||||
| pv | # Передача через HackRF
|
||||
/home/user/hackrf-liquid-dsp/hackrf-frame64-tx \
|
||||
-x 40 \
|
||||
-a 0 \
|
||||
-d a32868dc347c6e47 \
|
||||
-s 2000000 \
|
||||
-b 1750000 \
|
||||
-f 800000000
|
||||
240
scripts/emulator.py
Normal file
240
scripts/emulator.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Эмулятор чтения из файла и отправки в pipe.
|
||||
|
||||
Создание сокета: mkfifo /tmp/gpio_pipe
|
||||
Формат файла: [0xE0, 0x01, 0x4C, 0x88]
|
||||
Запуск: python3 emulator.py array.txt /tmp/gpio_pipe
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import ast
|
||||
import signal
|
||||
import os
|
||||
|
||||
OUT_BUFFER_LEN = 32
|
||||
RECONNECT_DELAY = 0.5
|
||||
REPEAT_COUNT = 100 # повтор
|
||||
DEFAULT_SEND_DELAY = 0.1 # по умолчанию (10 байт/с)
|
||||
|
||||
stop_flag = False
|
||||
|
||||
|
||||
# ================= SIGNALS =================
|
||||
|
||||
def handle_sigint(signum, frame):
|
||||
global stop_flag
|
||||
stop_flag = True
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
signal.signal(signal.SIGTERM, handle_sigint)
|
||||
|
||||
|
||||
# ================= UI =================
|
||||
|
||||
last_status = ""
|
||||
|
||||
|
||||
def status(msg):
|
||||
global last_status
|
||||
if msg != last_status:
|
||||
sys.stderr.write("\r" + msg + " " * 20)
|
||||
sys.stderr.flush()
|
||||
last_status = msg
|
||||
|
||||
|
||||
def status_clear():
|
||||
sys.stderr.write("\r" + " " * 80 + "\r")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
# ================= DATA =================
|
||||
|
||||
def load_array(path):
|
||||
"""Загружает данные из файла."""
|
||||
while True:
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
content = f.read().strip()
|
||||
|
||||
data = ast.literal_eval(content)
|
||||
|
||||
result = []
|
||||
for x in data:
|
||||
if isinstance(x, str):
|
||||
x = int(x, 0)
|
||||
result.append(x & 0xFF)
|
||||
|
||||
print(f"[emu] Загружено {len(result)} байт", file=sys.stderr)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"[emu] Ошибка чтения: {e}", file=sys.stderr)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
# ================= PIPE =================
|
||||
|
||||
def open_pipe(path):
|
||||
"""Открывает pipe для записи."""
|
||||
while not stop_flag:
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
status("[emu] pipe: подключен")
|
||||
return os.fdopen(fd, "wb", buffering=0)
|
||||
|
||||
except OSError:
|
||||
status("[emu] pipe: нет читателя")
|
||||
time.sleep(RECONNECT_DELAY)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ================= ARGS PARSING =================
|
||||
|
||||
def parse_args():
|
||||
"""
|
||||
Разбирает аргументы командной строки.
|
||||
Использование:
|
||||
python3 emulator.py array.txt /tmp/gpio_pipe [задержка] [logfile|-]
|
||||
|
||||
Где:
|
||||
задержка — float, секунд между байтами (по умолчанию DEFAULT_SEND_DELAY)
|
||||
logfile — путь к файлу лога или "-" для stderr
|
||||
"""
|
||||
if len(sys.argv) < 3:
|
||||
print("Использование: python emulator.py array.txt <pipe_path|-> [задержка] [logfile|-]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
array_path = sys.argv[1]
|
||||
pipe_path = sys.argv[2]
|
||||
|
||||
send_delay = DEFAULT_SEND_DELAY
|
||||
log_file = None
|
||||
arg_offset = 0
|
||||
|
||||
if len(sys.argv) > 3:
|
||||
try:
|
||||
send_delay = float(sys.argv[3])
|
||||
arg_offset = 1
|
||||
print(f"[emu] Задержка: {send_delay} с (~{1/send_delay:.0f} байт/с)", file=sys.stderr)
|
||||
except ValueError:
|
||||
arg_offset = 0
|
||||
|
||||
log_idx = 3 + arg_offset
|
||||
if len(sys.argv) > log_idx:
|
||||
log_path = sys.argv[log_idx]
|
||||
if log_path == "-":
|
||||
log_file = sys.stderr
|
||||
else:
|
||||
log_file = open(log_path, "a")
|
||||
|
||||
return array_path, pipe_path, send_delay, log_file
|
||||
|
||||
# ================= MAIN =================
|
||||
|
||||
def main():
|
||||
array_path, pipe_path, send_delay, log_file = parse_args()
|
||||
|
||||
data = load_array(array_path)
|
||||
|
||||
log_buffer = []
|
||||
total_bytes = 0
|
||||
packet_id = 0
|
||||
i = 0
|
||||
repeat = 0
|
||||
|
||||
# ===== STDOUT MODE =====
|
||||
if pipe_path == "-":
|
||||
pipe = sys.stdout.buffer # бинарный stdout
|
||||
print(f"[emu] Режим stdout, {len(data)} байт в цикле", file=sys.stderr)
|
||||
try:
|
||||
while not stop_flag:
|
||||
byte = data[i]
|
||||
pipe.write(bytes([byte]))
|
||||
pipe.flush()
|
||||
|
||||
log_buffer.append(byte)
|
||||
total_bytes += 1
|
||||
|
||||
if len(log_buffer) >= OUT_BUFFER_LEN and log_file:
|
||||
packet_id += 1
|
||||
log_file.write(f"--- Пакет #{packet_id} ({OUT_BUFFER_LEN} байт) ---\n")
|
||||
for idx, b in enumerate(log_buffer):
|
||||
log_file.write(f" [{idx:02d}] = 0x{b:02x} ({b:3d})\n")
|
||||
log_file.flush()
|
||||
log_buffer.clear()
|
||||
|
||||
repeat += 1
|
||||
if repeat >= REPEAT_COUNT:
|
||||
repeat = 0
|
||||
i += 1
|
||||
if i >= len(data):
|
||||
i = 0
|
||||
|
||||
time.sleep(send_delay)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except BrokenPipeError:
|
||||
pass # нормально для pipe
|
||||
finally:
|
||||
status_clear()
|
||||
print("[emu] завершён", file=sys.stderr)
|
||||
return
|
||||
|
||||
# ===== FIFO MODE =====
|
||||
|
||||
pipe = None
|
||||
|
||||
while not stop_flag:
|
||||
|
||||
if pipe is None:
|
||||
pipe = open_pipe(pipe_path)
|
||||
if pipe is None:
|
||||
break
|
||||
|
||||
try:
|
||||
byte = data[i]
|
||||
|
||||
pipe.write(bytes([byte]))
|
||||
|
||||
log_buffer.append(byte)
|
||||
total_bytes += 1
|
||||
|
||||
if len(log_buffer) >= OUT_BUFFER_LEN and log_file:
|
||||
packet_id += 1
|
||||
|
||||
log_file.write(f"--- Пакет #{packet_id} ({OUT_BUFFER_LEN} байт) ---\n")
|
||||
for idx, b in enumerate(log_buffer):
|
||||
log_file.write(f" [{idx:02d}] = 0x{b:02x} ({b:3d})\n")
|
||||
|
||||
log_file.flush()
|
||||
log_buffer.clear()
|
||||
|
||||
repeat += 1
|
||||
|
||||
if repeat >= REPEAT_COUNT:
|
||||
repeat = 0
|
||||
i += 1
|
||||
if i >= len(data):
|
||||
i = 0
|
||||
|
||||
time.sleep(send_delay)
|
||||
|
||||
except BrokenPipeError:
|
||||
pipe.close()
|
||||
pipe = None
|
||||
status("[emu] pipe: отключён")
|
||||
|
||||
except Exception as e:
|
||||
status(f"[emu] ошибка: {e}")
|
||||
time.sleep(1)
|
||||
|
||||
status_clear()
|
||||
print("[emu] завершён", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
36
scripts/go2monitor.service
Normal file
36
scripts/go2monitor.service
Normal file
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=GPIO Monitor Server
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=user
|
||||
Group=user
|
||||
WorkingDirectory=/home/user/temp/golang
|
||||
|
||||
# Запуск сервера с параметрами для 72-часового хранения
|
||||
ExecStart=/home/user/temp/golang/build/gpio-monitor-server \
|
||||
-pipe /tmp/gpio_pipe \
|
||||
-retention-hours=72 \
|
||||
-retention-mb=2000 \
|
||||
-retention-interval=60 \
|
||||
-rotation-check-interval=1 \
|
||||
-buffer-size=10240 \
|
||||
-human-log-interval=5
|
||||
|
||||
# Перезапуск при падении
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
# Лимиты и безопасность
|
||||
LimitNOFILE=4096
|
||||
NoNewPrivileges=yes
|
||||
|
||||
# Логирование
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=gpio-monitor
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
BIN
scripts/go2rtc
Normal file
BIN
scripts/go2rtc
Normal file
Binary file not shown.
19
scripts/go2rtc.yaml
Normal file
19
scripts/go2rtc.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
streams:
|
||||
# Основной поток для WebRTC (быстрый, минимальная задержка)
|
||||
cam:
|
||||
- exec:ffmpeg -f v4l2 -input_format yuyv422 -video_size 640x480 -framerate 30 -i /dev/video0 -c:v libx264 -preset ultrafast -tune zerolatency -f rawvideo pipe:1
|
||||
# MJPEG поток для браузера (оптимизирован под теги img)
|
||||
cam_mjpeg:
|
||||
- exec:ffmpeg -f v4l2 -input_format yuyv422 -video_size 640x480 -framerate 15 -i /dev/video0 -c:v mjpeg -q:v 8 -f mpjpeg pipe:1
|
||||
|
||||
api:
|
||||
listen: ":1984"
|
||||
|
||||
rtsp:
|
||||
listen: ":8554"
|
||||
|
||||
webrtc:
|
||||
listen: ":8555"
|
||||
|
||||
http:
|
||||
listen: ":4444"
|
||||
@@ -1,191 +1,177 @@
|
||||
import sys
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Имитатор параллельной шины для gpio-interrupt (Raspberry Pi 4)
|
||||
"""
|
||||
|
||||
import gpiod
|
||||
import time
|
||||
import ast
|
||||
import argparse
|
||||
import sys
|
||||
import signal
|
||||
import os
|
||||
|
||||
OUT_BUFFER_LEN = 32
|
||||
RECONNECT_DELAY = 0.5
|
||||
REPEAT_COUNT = 50 # повтор
|
||||
DEFAULT_SEND_DELAY = 0.1 # по умолчанию (10 байт/с)
|
||||
|
||||
stop_flag = False
|
||||
from gpiod.line import Direction, Value
|
||||
|
||||
|
||||
# ================= SIGNALS =================
|
||||
class ParallelBusImitator:
|
||||
"""Имитатор 7-битной параллельной шины данных"""
|
||||
|
||||
def handle_sigint(signum, frame):
|
||||
global stop_flag
|
||||
stop_flag = True
|
||||
# Временные константы (секунды)
|
||||
DATA_SETUP_TIME = 0.000005
|
||||
STROBE_WIDTH = 0.000005
|
||||
HOLD_TIME = 0.000005
|
||||
|
||||
WR_PIN = 12 # BCM12
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
signal.signal(signal.SIGTERM, handle_sigint)
|
||||
DATA_PINS_MAPPING = [
|
||||
(21, 18, "D0 (LSB)"),
|
||||
(7, 27, "D1"),
|
||||
(6, 22, "D2"),
|
||||
(5, 23, "D3"),
|
||||
(25, 17, "D4"),
|
||||
(24, 20, "D5"),
|
||||
(23, 21, "D6"),
|
||||
(22, None, "D7 (не подключён)"),
|
||||
]
|
||||
|
||||
def __init__(self, use_mask=True, chip_path='/dev/gpiochip0'):
|
||||
self.use_mask = use_mask
|
||||
self.counter = 0
|
||||
self.active_pins = []
|
||||
|
||||
# ================= UI =================
|
||||
for wpi, bcm, desc in self.DATA_PINS_MAPPING:
|
||||
if bcm is not None:
|
||||
self.active_pins.append((wpi, bcm, desc))
|
||||
|
||||
last_status = ""
|
||||
self.data_pins_bcm = [bcm for _, bcm, _ in self.active_pins]
|
||||
self.all_pins = self.data_pins_bcm + [self.WR_PIN]
|
||||
|
||||
|
||||
def status(msg):
|
||||
global last_status
|
||||
if msg != last_status:
|
||||
sys.stderr.write("\r" + msg + " " * 20)
|
||||
sys.stderr.flush()
|
||||
last_status = msg
|
||||
|
||||
|
||||
def status_clear():
|
||||
sys.stderr.write("\r" + " " * 80 + "\r")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
# ================= DATA =================
|
||||
|
||||
def load_array(path):
|
||||
while True:
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
content = f.read().strip()
|
||||
|
||||
data = ast.literal_eval(content)
|
||||
|
||||
result = []
|
||||
for x in data:
|
||||
if isinstance(x, str):
|
||||
x = int(x, 0)
|
||||
result.append(x & 0xFF)
|
||||
|
||||
print(f"[emu] Загружено {len(result)} байт", file=sys.stderr)
|
||||
return result
|
||||
|
||||
self.chip = gpiod.Chip(chip_path)
|
||||
self.lines = self.chip.request_lines(
|
||||
{
|
||||
p: gpiod.LineSettings(
|
||||
direction=Direction.OUTPUT,
|
||||
output_value=Value.INACTIVE
|
||||
)
|
||||
for p in self.all_pins
|
||||
},
|
||||
consumer="par-bus-imitator"
|
||||
)
|
||||
except PermissionError:
|
||||
print("Нет прав доступа к GPIO. Используйте sudo.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"[emu] Ошибка чтения: {e}", file=sys.stderr)
|
||||
time.sleep(1)
|
||||
print(f"Ошибка GPIO: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
bit_width = 4 if use_mask else len(self.data_pins_bcm)
|
||||
|
||||
# ================= PIPE =================
|
||||
print(f"✓ Имитатор шины ({bit_width}-бит режим)")
|
||||
print("Нажмите Ctrl+C для остановки\n")
|
||||
|
||||
def open_pipe(path):
|
||||
while not stop_flag:
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
status("[emu] pipe: подключен")
|
||||
return os.fdopen(fd, "wb", buffering=0)
|
||||
|
||||
except OSError:
|
||||
status("[emu] pipe: нет читателя")
|
||||
time.sleep(RECONNECT_DELAY)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ================= ARGS PARSING =================
|
||||
|
||||
def parse_args():
|
||||
"""
|
||||
Разбирает аргументы командной строки.
|
||||
Использование:
|
||||
python3 emulator.py array.txt /tmp/gpio_pipe [задержка] [logfile|-]
|
||||
|
||||
Где:
|
||||
задержка — float, секунд между байтами (по умолчанию DEFAULT_SEND_DELAY)
|
||||
logfile — путь к файлу лога или "-" для stderr
|
||||
"""
|
||||
if len(sys.argv) < 3:
|
||||
print("Использование: python emulator.py array.txt /tmp/gpio_pipe [задержка] [logfile|-]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
array_path = sys.argv[1]
|
||||
pipe_path = sys.argv[2]
|
||||
|
||||
send_delay = DEFAULT_SEND_DELAY
|
||||
log_file = None
|
||||
arg_offset = 0
|
||||
|
||||
if len(sys.argv) > 3:
|
||||
try:
|
||||
send_delay = float(sys.argv[3])
|
||||
arg_offset = 1 # сдвигаемся, значит logfile будет 4-м аргументом
|
||||
print(f"[emu] Задержка: {send_delay} с (~{1/send_delay:.0f} байт/с)", file=sys.stderr)
|
||||
except ValueError:
|
||||
arg_offset = 0
|
||||
|
||||
# Лог-файл
|
||||
log_idx = 3 + arg_offset
|
||||
if len(sys.argv) > log_idx:
|
||||
log_path = sys.argv[log_idx]
|
||||
if log_path == "-":
|
||||
log_file = sys.stderr
|
||||
def send_byte(self, value):
|
||||
"""Отправка одного байта"""
|
||||
if self.use_mask:
|
||||
value &= 0x0F
|
||||
else:
|
||||
log_file = open(log_path, "a")
|
||||
value &= 0x7F
|
||||
|
||||
return array_path, pipe_path, send_delay, log_file
|
||||
out = {}
|
||||
|
||||
for bit_pos, bcm_pin in enumerate(self.data_pins_bcm):
|
||||
bit = (value >> bit_pos) & 1
|
||||
out[bcm_pin] = Value.ACTIVE if bit else Value.INACTIVE
|
||||
|
||||
self.lines.set_values(out)
|
||||
time.sleep(self.DATA_SETUP_TIME)
|
||||
|
||||
self.lines.set_value(self.WR_PIN, Value.ACTIVE)
|
||||
time.sleep(self.STROBE_WIDTH)
|
||||
self.lines.set_value(self.WR_PIN, Value.INACTIVE)
|
||||
time.sleep(self.HOLD_TIME)
|
||||
|
||||
self.counter += 1
|
||||
self.show_status(value)
|
||||
|
||||
def show_status(self, value):
|
||||
"""Красивый статус без спама"""
|
||||
bits = 4 if self.use_mask else 7
|
||||
bin_val = format(value, f'0{bits}b')
|
||||
|
||||
msg = (
|
||||
f"\r▶ Передача: "
|
||||
f"HEX=0x{value:02X} | "
|
||||
f"BIN={bin_val} | "
|
||||
f"Пакетов: {self.counter}"
|
||||
)
|
||||
|
||||
print(msg, end='', flush=True)
|
||||
|
||||
def send_sequence(self, values, delay=0.2):
|
||||
for val in values:
|
||||
self.send_byte(val)
|
||||
time.sleep(delay)
|
||||
|
||||
def cleanup(self):
|
||||
if hasattr(self, 'lines'):
|
||||
self.lines.release()
|
||||
if hasattr(self, 'chip'):
|
||||
self.chip.close()
|
||||
|
||||
|
||||
# ================= MAIN =================
|
||||
def handle_signal(signum, frame):
|
||||
print("\nЗавершение работы...")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
array_path, pipe_path, send_delay, log_file = parse_args()
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
|
||||
data = load_array(array_path)
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Имитатор параллельной шины'
|
||||
)
|
||||
|
||||
log_buffer = []
|
||||
total_bytes = 0
|
||||
packet_id = 0
|
||||
i = 0
|
||||
repeat = 0
|
||||
parser.add_argument('--no-mask', action='store_true',
|
||||
help='7-битный режим')
|
||||
parser.add_argument('--test',
|
||||
choices=['default', 'counter', 'pattern'],
|
||||
default='default')
|
||||
parser.add_argument('--delay', type=float, default=0.2)
|
||||
parser.add_argument('--single', type=str)
|
||||
parser.add_argument('--chip', default='/dev/gpiochip0')
|
||||
|
||||
pipe = None
|
||||
args = parser.parse_args()
|
||||
|
||||
while not stop_flag:
|
||||
imitator = ParallelBusImitator(
|
||||
use_mask=not args.no_mask,
|
||||
chip_path=args.chip
|
||||
)
|
||||
|
||||
if pipe is None:
|
||||
pipe = open_pipe(pipe_path)
|
||||
if pipe is None:
|
||||
break
|
||||
try:
|
||||
if args.single:
|
||||
val = int(args.single, 16)
|
||||
imitator.send_byte(val)
|
||||
print()
|
||||
return
|
||||
|
||||
try:
|
||||
byte = data[i]
|
||||
if args.test == 'default':
|
||||
test_data = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40]
|
||||
elif args.test == 'counter':
|
||||
test_data = list(range(128))
|
||||
else:
|
||||
test_data = [0x00, 0x01, 0x03, 0x07, 0x0F, 0x55, 0x2A]
|
||||
|
||||
pipe.write(bytes([byte]))
|
||||
while True:
|
||||
imitator.send_sequence(test_data, args.delay)
|
||||
|
||||
log_buffer.append(byte)
|
||||
total_bytes += 1
|
||||
|
||||
if len(log_buffer) >= OUT_BUFFER_LEN and log_file:
|
||||
packet_id += 1
|
||||
|
||||
log_file.write(f"--- Пакет #{packet_id} ({OUT_BUFFER_LEN} байт) ---\n")
|
||||
for idx, b in enumerate(log_buffer):
|
||||
log_file.write(f" [{idx:02d}] = 0x{b:02x} ({b:3d})\n")
|
||||
|
||||
log_file.flush()
|
||||
log_buffer.clear()
|
||||
|
||||
repeat += 1
|
||||
|
||||
if repeat >= REPEAT_COUNT:
|
||||
repeat = 0
|
||||
i += 1
|
||||
if i >= len(data):
|
||||
i = 0
|
||||
|
||||
time.sleep(send_delay)
|
||||
|
||||
except BrokenPipeError:
|
||||
pipe.close()
|
||||
pipe = None
|
||||
status("[emu] pipe: отключён")
|
||||
|
||||
except Exception as e:
|
||||
status(f"[emu] ошибка: {e}")
|
||||
time.sleep(1)
|
||||
|
||||
status_clear()
|
||||
print("[emu] завершён", file=sys.stderr)
|
||||
except KeyboardInterrupt:
|
||||
print("\nОстановка...")
|
||||
except Exception as e:
|
||||
print(f"\nОшибка: {e}")
|
||||
finally:
|
||||
imitator.cleanup()
|
||||
print("✓ GPIO освобождены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
36
scripts/view_logs.sh
Normal file
36
scripts/view_logs.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
LOG_DIR="$HOME/.local/share/gpio-monitoring/logs"
|
||||
|
||||
echo "=== СТАТИСТИКА ЛОГОВ ==="
|
||||
echo ""
|
||||
|
||||
if [ -d "$LOG_DIR" ]; then
|
||||
echo "Логи в: $LOG_DIR"
|
||||
echo ""
|
||||
echo "Общая статистика:"
|
||||
echo " Событий (JSON): $(wc -l < $LOG_DIR/events.log 2>/dev/null || echo 0)"
|
||||
echo " Событий (Human): $(wc -l < $LOG_DIR/events_human.log 2>/dev/null || echo 0)"
|
||||
echo " Записей GPIO: $(wc -l < $LOG_DIR/gpio_human.log 2>/dev/null || echo 0)"
|
||||
echo ""
|
||||
|
||||
echo "оследние 5 предупреждений:"
|
||||
grep "ВНИМАНИЕ" $LOG_DIR/gpio_human.log 2>/dev/null | tail -5 || echo " Нет предупреждений"
|
||||
echo ""
|
||||
|
||||
echo "Статистика по значениям:"
|
||||
grep "Значение GPIO:" $LOG_DIR/gpio_human.log 2>/dev/null | awk -F': ' '{print $2}' | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
|
||||
echo ""
|
||||
|
||||
echo "💾 Использование диска:"
|
||||
du -sh $LOG_DIR 2>/dev/null
|
||||
echo ""
|
||||
|
||||
if ls $LOG_DIR/data/*.bin 1> /dev/null 2>&1; then
|
||||
echo "📁 Файлы данных:"
|
||||
ls -lh $LOG_DIR/data/*.bin 2>/dev/null | awk '{print " " $9 ": " $5}'
|
||||
fi
|
||||
else
|
||||
echo "❌ Директория логов не найдена: $LOG_DIR"
|
||||
echo "Запустите сервер для создания логов"
|
||||
fi
|
||||
61
scripts/vu_meter.py
Normal file
61
scripts/vu_meter.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import sys
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
|
||||
# Настройки
|
||||
DURATION = 0.2 # Увеличили буфер для стабильности на Raspberry Pi
|
||||
SAMPLE_RATE = 44100 # Частота дискретизации
|
||||
|
||||
|
||||
def audio_callback(indata, frames, time, status):
|
||||
# Игнорируем input overflow, чтобы не засорять вывод,
|
||||
# но выводим другие критические ошибки, если они будут
|
||||
if status and not status.input_overflow:
|
||||
print(f"\nСтатус: {status}", file=sys.stderr)
|
||||
|
||||
# Вычисляем среднеквадратичное значение (RMS)
|
||||
rms = np.sqrt(np.mean(indata**2))
|
||||
|
||||
# Переводим в проценты.
|
||||
# Коэффициент 250 хорошо подходит для стандартных микрофонов
|
||||
volume_percent = min(int(rms * 250), 100)
|
||||
|
||||
# Рисуем текстовый индикатор
|
||||
bar_length = int(volume_percent / 2) # Шкала из 50 символов max
|
||||
bar = "█" * bar_length + "-" * (50 - bar_length)
|
||||
|
||||
# Выводим в консоль с очисткой конца строки (\033[K), чтобы убрать артефакты
|
||||
sys.stdout.write(f"\rУровень: {volume_percent:3}% | [{bar}]\033[K")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
print("Поиск аудиоустройств...")
|
||||
|
||||
device_id = None
|
||||
devices = sd.query_devices()
|
||||
for idx, dev in enumerate(devices):
|
||||
# Ищем устройство без учета регистра
|
||||
if "pcm2902" in dev["name"].lower() and dev["max_input_channels"] > 0:
|
||||
device_id = idx
|
||||
print(f"Найдено устройство: {dev['name']} (ID: {idx})")
|
||||
break
|
||||
|
||||
if device_id is None:
|
||||
print("Используем аудиоустройство по умолчанию.")
|
||||
|
||||
print("Запуск мониторинга уровня звука. Для выхода нажмите Ctrl+C\n")
|
||||
|
||||
try:
|
||||
with sd.InputStream(
|
||||
device=device_id,
|
||||
channels=1,
|
||||
samplerate=SAMPLE_RATE,
|
||||
blocksize=int(SAMPLE_RATE * DURATION),
|
||||
callback=audio_callback,
|
||||
):
|
||||
while True:
|
||||
sd.sleep(100)
|
||||
except KeyboardInterrupt:
|
||||
print("\nМониторинг остановлен.")
|
||||
except Exception as e:
|
||||
print(f"\nОшибка: {e}")
|
||||
Reference in New Issue
Block a user