Compare commits

...

65 Commits

Author SHA1 Message Date
Maxim
9fa3d172a8 Доработка документации 2026-07-17 15:57:05 +03:00
Maxim
5b403d7ee4 Доработка версий сборок через git 2026-07-13 10:50:39 +03:00
Maxim
48aa91b8cd Доработка документации и вывода сообщений при ошибках 2026-07-13 09:35:42 +03:00
Maxim
1b305500a8 Доработка ротацию лог файлов 2026-07-13 09:11:34 +03:00
Maxim
4509753ac5 корректировка Scroll Chaining 2026-07-06 15:38:07 +03:00
Maxim
243541fcd6 коррекция индикации микрофона 2026-07-06 15:18:26 +03:00
Maxim
8dcaee3d61 корректировка 2026-07-03 11:21:12 +03:00
Maxim
5a9ce4f5c4 доработка Makefile 2026-07-03 11:20:01 +03:00
Maxim
51400a5762 chore: save executable permissions for scripts 2026-07-03 10:25:52 +03:00
Maxim
19c1919fc5 Доработка звука 2026-07-03 09:21:02 +03:00
Maxim
594a62a9f6 Доработка сборки deb пакета 2026-06-23 15:28:10 +03:00
Maxim
387823d36a Доработка git 2026-06-23 11:26:23 +03:00
Maxim
118e49c7f9 Отладка звука 2026-06-23 10:10:14 +03:00
Maxim
78069b0aa4 В процессе работы 2026-06-23 09:07:43 +03:00
Maxim
877b29d5b4 В процессе работы 2026-06-22 14:38:07 +03:00
Maxim
eab9e17212 Исправление индикации 2026-06-22 11:54:14 +03:00
Maxim
827210d29a Корректировка ошибок. Стили, прокрутка, шрифты 2026-06-22 10:41:29 +03:00
Maxim
5d15f215be Добавление 8 каналов в ui 2026-06-19 15:56:30 +03:00
Maxim
311d09f0b3 Оптимизация журналирования событий 2026-06-19 10:34:07 +03:00
Maxim
7fc5b79bf5 Боковая панель в данных для журнала 2026-06-18 15:24:24 +03:00
Maxim
6f7617b174 Обработка ситуации, когда нет данных в файле, или ошибка при загрузке 2026-06-18 15:14:37 +03:00
Maxim
d8b25e188d Ренейм 2026-06-18 14:51:10 +03:00
Maxim
18d005c933 Корректировка данных в логах 2026-06-18 14:40:59 +03:00
Maxim
9bbcd67065 Корректировка последовательности логов 2026-06-18 14:16:11 +03:00
Maxim
627c18c2b3 Инфентировал страницы логи и стили добавил 2026-06-18 13:38:17 +03:00
Maxim
e92c7d8f09 Шрифты 2026-06-18 11:27:03 +03:00
Maxim
fd627257f5 Рефактор 2026-06-18 11:01:56 +03:00
Maxim
35caa2bb72 Доработка Пагинации в логи 2026-06-18 11:01:39 +03:00
Maxim
0cea7d5e9d Рефактор 2026-06-18 10:39:34 +03:00
Maxim
ec16f2467f Добавление makefile 2026-06-18 09:53:31 +03:00
Maxim
d785f4a481 корректировка отображения данных в журнале 2026-06-17 16:35:32 +03:00
Maxim
580b78e5c0 регулировка ресайза 2026-06-17 15:59:53 +03:00
Maxim
30923eb9ce рефактор 2026-06-17 15:25:46 +03:00
Maxim
5d2d37fc7b доработка звука по уровням, разный тон 2026-06-17 11:43:58 +03:00
Maxim
5938d94a8f доработка звука при обнаружении амплитуды сигнала 2026-06-17 11:19:53 +03:00
Maxim
4180353b65 доработка README 2026-06-17 09:53:29 +03:00
Maxim
bc40c1437a доработка текущего времени raspberry на экране 2026-06-16 13:23:06 +03:00
Maxim
875aa8a054 доработка отображения пик холдера в прогресс баре 2026-06-16 11:09:02 +03:00
Maxim
9525097af6 Доработка алгоритма пик ходера для уровня сигнала (амплитуды от обнаружителя) 2026-06-16 08:30:38 +03:00
Maxim
9a17569ac0 Доработка конфигурации логов 2026-06-15 15:41:54 +03:00
Maxim
87ab5d48de Обновление скрипта сборки и эмулятора 2026-06-11 10:39:52 +03:00
Maxim
2dc1bb647f Рефакторинг. Смена среднего скользящего на PeakHolder по таймеру 2026-06-11 10:36:47 +03:00
Maxim
96c280c2b8 Рефакторинг. README 2026-06-11 09:03:32 +03:00
Maxim
ea972129cb Рефакторинг. Переход на typescript 2026-06-11 08:59:11 +03:00
Maxim
b4649f61ca Доработка. СКВ 2026-06-11 08:56:11 +03:00
Maxim
40ac0dd2b4 Доработка. Авто старт 2026-06-09 16:10:42 +03:00
Maxim
730facdf59 Доработка. Использование стандартных путей Linux 2026-06-09 16:02:47 +03:00
Maxim
d84856ba3a Доработка. Защита от переполнения диска 2026-06-09 11:29:02 +03:00
Maxim
676a674794 Доработка. Парсинг в логах 2026-06-09 11:19:32 +03:00
Maxim
354156e557 Доработка. Журналирование 2026-06-09 11:00:20 +03:00
Maxim
ac0e3944ab СКВ 2026-06-04 09:05:21 +03:00
Maxim
3cceda32f4 Readme 2026-06-03 14:36:15 +03:00
Maxim
24b7cc402e Рефакторинг 2026-06-03 14:23:30 +03:00
Maxim
9d6452c860 Доработка синхронизации 2026-06-03 13:28:29 +03:00
Maxim
b00af27058 Работающая версия с PLuto + liquidDSP 2026-05-28 09:08:51 +03:00
Maxim
8e296daacc Дорабтка c Pluto SDR 2026-05-27 14:13:57 +03:00
Maxim
941a7cf6e6 Дорабтка c HackRF 2026-05-20 11:30:45 +03:00
Maxim
f6416ca106 Корректировка видеокамеры 2026-05-12 09:42:13 +03:00
Tot Maxim
1a5e50ae1f Добавление go2rtc настроек и бинарник 2026-05-11 17:14:45 +03:00
Tot Maxim
361ffc5578 добавление креста метки на видео 2026-05-11 16:18:26 +03:00
Tot Maxim
ae56334637 доработка кнопки камеры в хедере 2026-05-11 15:43:06 +03:00
Tot Maxim
02e6bd7b47 интерфейс камеры откорректирован 2026-05-11 14:46:01 +03:00
Tot Maxim
f4a21e6974 исправлен лаг лейаута 2026-05-11 14:22:05 +03:00
Tot Maxim
5433a036aa камера на локальном хосте теперь 2026-05-11 14:02:05 +03:00
Tot Maxim
e36823d3ba Добавление камеры на сервер 2026-05-09 18:20:00 +03:00
91 changed files with 10843 additions and 915 deletions

5
.claudeignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.git/
dist/
build/
*.log

31
.gitattributes vendored Normal file
View 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
View File

@@ -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
View 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
View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View File

@@ -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 "СБОРКА ЗАВЕРШЕНА!"

View File

@@ -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
View 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).

View 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
View 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;
}
}

View 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);
}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}
}

View 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
View 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;
}

View 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);
}

View File

@@ -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;
}
}

View File

@@ -1,73 +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>ГРАФИК КАНАЛА 1</h3>
<canvas id="signalChart" height="120"></canvas>
<div class="hotkey-hint">
(ENG lang key) <kbd>C</kbd> — камера &nbsp;|&nbsp; <kbd>M</kbd> — звук &nbsp;|&nbsp; <kbd>Esc</kbd>сброс
</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>
<script type="module" src="/js/app.js"></script>
<script type="module" src="/dist/app.js"></script>
</body>
</html>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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);
}
});
}
}

View File

@@ -1,156 +0,0 @@
// app.js (оркестратор)
import { fetchHealth, fetchHistory } from "./data.js";
import { state, smooth, addSignals, resetBufferTracking } 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;
// ================= 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);
if (!connectionLost) {
resetBufferTracking();
}
connectionLost = true;
}
const arr = Array.isArray(hist.bytes) ? hist.bytes : [];
const raw = arr[arr.length - 1] ?? 0;
// ===== ДОБАВЛЯЕМ ТОЛЬКО НОВЫЕ БАЙТЫ =====
if (arr.length > 0 && isOnline) {
const bufferSize = h.stats?.filled ?? 0;
addSignals(arr, bufferSize);
}
// ===== DECODE =====
const value = raw & 0x3F;
const level = (raw >> 6) & 0x3;
const percent = (value / 63) * 100;
// ===== 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?.bytes_per_sec ?? 0);
setMeta(
`Работа: ${uptime}s | Буфер: ${filled} | Приём: ${Bps} B/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);
resetBufferTracking();
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
View 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
View 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
View 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);
}
}

View File

@@ -1,32 +1,30 @@
// chart.js
export function drawChart(canvas, history) {
export function drawChart(canvas: HTMLCanvasElement, history: number[]): void {
if (!canvas || !history?.length) return;
const rect = canvas.getBoundingClientRect();
const displayHeight = rect.height || 200;
const displayWidth = rect.width || 800;
canvas.width = displayWidth;
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;
@@ -37,7 +35,7 @@ export function drawChart(canvas, history) {
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);
@@ -46,12 +44,12 @@ export function drawChart(canvas, history) {
ctx.fillStyle = "#00ff88";
ctx.font = "11px monospace";
ctx.textAlign = "right";
ctx.fillText(Math.round(val), padLeft - 8, y + 4);
ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4);
}
ctx.setLineDash([]);
// ===== ПОДПИСЬ ОСИ Y =====
// Подписи осей
ctx.fillStyle = "#00ff88";
ctx.font = "bold 11px monospace";
@@ -62,13 +60,11 @@ export function drawChart(canvas, history) {
ctx.fillText("ЗНАЧЕНИЕ", 0, 0);
ctx.restore();
// ===== ПОДПИСЬ ОСИ X =====
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";
@@ -77,7 +73,6 @@ export function drawChart(canvas, history) {
return;
}
// Прореживание если точек больше чем пикселей
let plotData = history;
if (history.length > plotW) {
const step = history.length / plotW;
@@ -92,15 +87,13 @@ export function drawChart(canvas, history) {
ctx.lineWidth = 1.5;
ctx.shadowColor = "#e6852d";
ctx.shadowBlur = 3;
ctx.beginPath();
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);
}
@@ -108,39 +101,33 @@ export function drawChart(canvas, history) {
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";

View File

@@ -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
View 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
View 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
View 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();
}

View File

@@ -1,42 +0,0 @@
// state.js (вся логика состояния)
export const state = {
maxPoints: 1000,
signalHistory: [],
smoothValue: 0,
lastBufferSize: 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.splice(0, state.signalHistory.length - state.maxPoints);
}
}
export function addSignals(values, bufferSize) {
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.splice(0, state.signalHistory.length - state.maxPoints);
}
}
export function resetBufferTracking() {
state.lastBufferSize = 0;
}

223
cmd/server/web/js/state.ts Normal file
View 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();
}

View File

@@ -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
View 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
View 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>

View 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"
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
# Бинарник будет установлен в /usr/bin автоматически
debian/usr/bin/gpio-logs usr/bin/

33
debian/postinst vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 (0255) | Последний сырой байт 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 (0100) | Уровень звука с микрофона; `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` — массив целых (0255), до **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` биты 67 (03); `signal` биты 05 (063); `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
View 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 и публикует уровень 0100 (`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` (блокируется навсегда).
Ошибка инициализации любого логгера (шаги 25) — `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
View 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
(03) (063)
```
| Поле | Биты | Диапазон | Смысл |
|---|---|---|---|
| `count` (в API — `signal`) | 05 | 063 | Количество обнаружений |
| `strength` (в API — `amplitude`) | 67 | 03 | Сила сигнала |
Текстовые имена уровней силы (`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:0012: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
View 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
View 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 (сетка, шкала 063) |
| `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
View 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
View 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:297298``internal/logger/parser.go` | Риск рассинхронизации формата | Использовать `ParseGPIO` |
| 5 | `/api/log/data` читает весь `.bin`-файл в память до пагинации | `main.go:278308` | На больших файлах — всплеск памяти на каждый запрос | Читать нужный диапазон по смещению (записи фиксированные, 9 байт) |
| 6 | CORS открыт для всех источников (`*`) | `main.go:522535` | Приемлемо для изолированной сети; риск при выходе наружу | Осознанное решение зафиксировано; при необходимости — 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:77162` | Значение `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:6670` | Потеря данных при полном диске останется незамеченной | Прокинуть 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:249256` | Цели заведомо падают | Удалить цели или добавить 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 (актуален/эксперимент/заморожен) |

View File

@@ -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
View File

@@ -0,0 +1,222 @@
// Package audio измеряет уровень звука с микрофона через arecord (ALSA):
// USB-устройство находится автоматически, PCM-поток читается блоками по 100 мс,
// уровень считается как RMS и публикуется по шкале 0100 (поле 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 возвращает текущий уровень звука по шкале 0100
// (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
View 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,
}
}

View 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()
}

View 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
}

View 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()
}

View 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
View File

@@ -0,0 +1,42 @@
package logger
// GPIOData — разобранный байт GPIO-шины: количество обнаружений (биты 05),
// сила сигнала (биты 67) и признак превышения порога тревоги.
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
View 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"
}

View 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)
}

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
[0xE0, 0x01, 0x4C, 0x88]

15
scripts/emulatohackrf.sh Normal file
View 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
View 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()

View 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

Binary file not shown.

19
scripts/go2rtc.yaml Normal file
View 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"

View File

@@ -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
View 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
View 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}")