Compare commits

...

23 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
62 changed files with 4856 additions and 1684 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

14
.gitignore vendored
View File

@@ -105,4 +105,16 @@ logs/
# Файлы дампа памяти
*.dump
*.core
*.core
# Debian packaging
*.deb
*.dsc
*.changes
debian/*.log
debian/*.debhelper
debian/*.substvars
debian/files
debian/gpio-monitor-server/
debian/debhelper-build-stamp
debian/.debhelper/

155
Makefile
View File

@@ -1,6 +1,6 @@
# Makefile для GPIO Monitor Server
.PHONY: all build clean run test fmt lint help install dev
.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
@@ -8,11 +8,20 @@ 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"
@@ -22,6 +31,24 @@ 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)"
# ============================================
# ОСНОВНЫЕ ЦЕЛИ
# ============================================
@@ -43,15 +70,15 @@ build-frontend:
fi && \
echo " Компиляция TypeScript..." && \
npm run build && \
echo "$(GREEN) TypeScript скомпилирован успешно!$(NC)"
echo "$(GREEN)TypeScript скомпилирован успешно!$(NC)"
## build-backend - Сборка Go сервера
build-backend:
@echo "$(YELLOW)🔧 Сборка Go сервера...$(NC)"
@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)"
@echo "$(GREEN)Go сервер скомпилирован успешно!$(NC)"
## run - Запуск сервера (сборка + запуск)
run: build
@@ -60,7 +87,7 @@ run: build
## dev - Режим разработки (без оптимизации, с hot-reload)
dev:
@echo "$(YELLOW) Режим разработки...$(NC)"
@echo "$(YELLOW)Режим разработки...$(NC)"
@go run $(CMD_PATH) -port :8080
## clean - Очистка артефактов сборки
@@ -69,7 +96,9 @@ clean:
@rm -rf $(BUILD_DIR)
@rm -rf $(WEB_DIR)/dist
@rm -rf $(WEB_DIR)/node_modules
@echo "$(GREEN) Очищено$(NC)"
@rm -rf $(DEB_PACKAGE)
@rm -f *.deb
@echo "$(GREEN)Очищено$(NC)"
## test - Запуск тестов
test:
@@ -78,31 +107,32 @@ test:
## fmt - Форматирование кода
fmt:
@echo "$(YELLOW) Форматирование кода...$(NC)"
@echo "$(YELLOW)Форматирование кода...$(NC)"
@go fmt ./...
@cd $(WEB_DIR) && npm run format 2>/dev/null || true
## lint - Проверка кода
lint:
@echo "$(YELLOW) Проверка кода...$(NC)"
@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) Установка зависимостей...$(NC)"
@go mod download
@go mod tidy
@cd $(WEB_DIR) && npm install
@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)"
@echo "$(GREEN)Установлено в /usr/local/bin/$(APP_NAME)$(NC)"
## uninstall - Удаление из системы
uninstall:
@echo "$(YELLOW)Удаление $(APP_NAME)...$(NC)"
@echo "$(YELLOW)🗑Удаление $(APP_NAME)...$(NC)"
@sudo rm -f /usr/local/bin/$(APP_NAME)
@echo "$(GREEN)Удалено$(NC)"
@@ -123,6 +153,73 @@ help:
@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
# ============================================
# ДОПОЛНИТЕЛЬНЫЕ ЦЕЛИ
@@ -157,3 +254,33 @@ docker-build:
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

428
README.md
View File

@@ -1,393 +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 (C + WiringPi)
✅ FIFO pipe, а также stdin/stdout между C + WiringPi и Go Service
Реализация кольцевого буфера чтения
✅ HTTP API + Web Dashboard
**Автоматическая ротация** лог-файлов
**Retention** (автоматическая очистка старых логов)
**Watchdog** мониторинг активности
**MJPEG камера** через go2rtc
TypeScript фронтенд
✅ Захват данных по 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 (C + WiringPi)
↓ stdout
прерывание
gpio-interrupt (C + WiringPi) ← поставляется отдельно, вне репозитория
↓ stdout → systemd socket
FIFO pipe (/tmp/gpio_pipe)
Go Server (gpio-monitor-server)
├── RingBuffer (RAM)
├── Data Logger (бинарные .bin файлы)
├── Human Logger (текстовые логи)
├── Event Logger (события системы)
├── Watchdog (мониторинг)
└── Retention (очистка старых логов)
├── Data Logger (почасовые .bin)
├── Human / Event Logger (текстовые логи)
├── Watchdog + Retention
├── Audio Monitor (arecord)
└── HTTP API (:8080) + Web Dashboard (embedded)
HTTP API (:8080)
├── /api/health - статус сервера
├── /api/latest - последние данные
├── /api/history - история (последние 100)
├── /api/stream - статус камеры
├── /api/cam - MJPEG поток
├── /api/log/files - список лог-файлов
├── /api/log/data - данные из лог-файла
└── /api/log/events - события системы
Web Dashboard (Embedded)
├── index.html
├── dashboard.html (главная)
├── logs.html (просмотр логов)
├── css/style.css
├── js/*.ts (TypeScript исходники)
└── dist/*.js (скомпилированный JS)
Браузер: 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).
---
## Структура проекта
```
.
├── build/
│ └── gpio-monitor-server # Скомпилированный бинарник
├── build.sh # Скрипт сборки (Go + TypeScript)
├── Makefile # Сборка (основной способ)
├── build.sh # Исторический скрипт сборки
├── start-server.sh # Скрипт запуска на устройстве
├── build/ # Артефакты сборки (генерируется)
├── cmd/server/
│ ├── main.go # Go HTTP сервер
│ ├── main.go # Go HTTP-сервер (точка входа)
│ └── web/ # Веб-интерфейс
│ ├── css/style.css
│ ├── dist/ # Скомпилированный JS
│ ├── app.js
│ ├── chart.js
│ ├── data.js
│ │ └── ...
│ ├── js/ # TypeScript исходники
│ │ ├── app.ts
│ │ ├── chart.ts
│ │ ├── data.ts
│ │ └── ...
│ ├── dashboard.html
│ ├── index.html
│ ├── logs.html
│ ├── favicon.png
│ ├── package.json
│ ├── tsconfig.json
│ ├── 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 # RingBuffer
│ ├── 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 # Очистка старых логов
│ │ ── rotation.go # Ротация файлов
│ │ ├── 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/
│ ├── monitor-gpio.service # Systemd сервис
│ ├── monitor-gpio.socket # Systemd socket
│ ├── go2rtc # Бинарник go2rtc
│ ├── go2rtc.yaml # Конфиг go2rtc
│ ├── view_logs.sh # Скрипт просмотра логов
│ ├── emulator.py # Эмулятор GPIO
── imi_wire.py # IMI Wire эмулятор
├── SDR/ # SDR компоненты
── common.c/h
│ ├── reciever.c
│ ├── transmitter.c
── makefile
├── go.mod
├── 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 пины
Каталоги `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
```
---
## Установка
## Быстрый старт
### 1. Установка зависимостей
### Сборка
```bash
sudo apt update
sudo apt install -y golang git build-essential wiringpi nodejs npm
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
```
### 2. Клонирование и сборка
Подробности, кросс-компиляция, эмуляция без железа — [docs/development.md](docs/development.md).
```bash
git clone <your-repo>
cd ~/work/golang
# Сборка TypeScript + Go
./build.sh
```
### 3. Настройка systemd сервиса
```bash
# Копировать сервисные файлы
sudo cp scripts/monitor-gpio.service /etc/systemd/system/
sudo cp scripts/monitor-gpio.socket /etc/systemd/system/
# Перезагрузить systemd
sudo systemctl daemon-reload
# Включить автозапуск
sudo systemctl enable monitor-gpio.socket
sudo systemctl enable monitor-gpio.service
# Запустить сервис
sudo systemctl start monitor-gpio.service
```
### 4. Проверка статуса
```bash
sudo systemctl status monitor-gpio.service
journalctl -u monitor-gpio.service -f
```
---
## Настройка firewall (nftables)
**Важно:** На Raspberry Pi OS Bookworm по умолчанию активен nftables, который блокирует все входящие порты, кроме SSH (22).
### Разрешить порт 8080:
```bash
# Добавить правило для порта 8080
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
# Разрешить только с сети 10.1.1.0/24
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:
```bash
# Разрешить с Wi-Fi сети (192.168.77.0/24)
sudo nft add rule inet filter input iifname "wlan0" tcp dport 8080 accept
```
---
## Запуск
### Ручной запуск
### Запуск
```bash
./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
-retention-mb=2000
```
### Параметры командной строки
Дашборд: `http://<IP>:8080` (через Wi-Fi AP — `http://192.168.77.1:8080`, локально — `http://localhost:8080`). Страницы: `/dashboard.html` — мониторинг, `/logs.html` — история.
| Параметр | По умолчанию | Описание |
|----------|-------------|----------|
| `-pipe` | `/tmp/gpio_pipe` | Путь к FIFO pipe |
| `-port` | `:8080` | Порт веб-сервера |
| `-retention-hours` | `48` | Часы хранения логов (0 = отключено) |
| `-retention-mb` | `5000` | Максимальный размер логов в MB |
| `-retention-interval` | `15` | Интервал проверки retention (мин) |
| `-rotation-check-interval` | `1` | Интервал проверки ротации (мин) |
| `-buffer-size` | `10240` | Размер кольцевого буфера |
| `-human-log-interval` | `5` | Интервал записи human-логов (сек) |
| `-alert-cooldown` | `10` | Задержка между алертами (сек) |
| `-camera-url` | `http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg` | URL MJPEG камеры |
Полная таблица из 13 CLI-флагов, настройка systemd, firewall (nftables), камеры и микрофона — [docs/operations.md](docs/operations.md).
---
## Dashboard
Открывайте в браузере:
- **Через Ethernet:** `http://10.1.1.33:8080` (или ваш IP)
- **Через Wi-Fi AP:** `http://192.168.77.1:8080`
- **Локально:** `http://localhost:8080`
### Страницы
- `http://<IP>:8080/` - Перенаправление на dashboard
- `http://<IP>:8080/dashboard.html` - Главная панель мониторинга
- `http://<IP>:8080/logs.html` - Просмотр исторических логов
---
## API
### Основные эндпоинты
### Deb-пакет
```bash
GET /api/health
# Статус сервера, uptime, статистика
GET /api/latest
# Последние данные и история (10 записей)
GET /api/history
# Последние 100 записей
GET /api/stream
# Статус камеры
GET /api/cam
# MJPEG поток камеры (прокси на go2rtc)
make deb # от обычного пользователя, без sudo
sudo dpkg -i build/gpio-monitor-server_1.0.0-build<N>_arm64.deb
```
### Работа с логами
```bash
GET /api/log/files
# Список доступных лог-файлов
GET /api/log/data?file=gpio-2026-06-17-11.bin
# Данные из конкретного бинарного лога
GET /api/log/events?limit=100
# Последние события системы
```
Версия пакета формируется автоматически: `<база из 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-пакета).
---
## Диагностика проблем
## Хранение данных (кратко)
### Если Dashboard не открывается:
Бинарные данные пишутся в почасовые файлы `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 свежих файла.
1. **Проверьте, что сервер запущен:**
```bash
ps aux | grep gpio-monitor-server
sudo systemctl status monitor-gpio.service
```
2. **Проверьте firewall:**
```bash
sudo nft list ruleset | grep 8080
```
3. **Проверьте, на каком интерфейсе слушает сервер:**
```bash
sudo ss -tlnp | grep 8080
# Должно быть *:8080 или 0.0.0.0:8080
```
4. **Проверьте маршрутизацию:**
```bash
ip route show
```
5. **Посмотрите логи:**
```bash
sudo journalctl -u monitor-gpio.service -f
tail -f /home/user/logs/gpio-events.log
tail -f /home/user/logs/gpio-human.log
```
6. **Проверьте наличие pipe:**
```bash
ls -la /tmp/gpio_pipe
```
Форматы файлов, словарь событий и алгоритм очистки — [docs/data-formats.md](docs/data-formats.md).
---
## Wi-Fi режим Raspberry Pi
SSID: `fix_me`
IP: `192.168.77.1`
Подключение: `ssh user@192.168.77.1`
---
### Запуск Go сервера в режиме разработки:
## Диагностика (кратко)
```bash
go run cmd/server/main.go \
-pipe /tmp/gpio_pipe \
-retention-hours=1 \
-human-log-interval=1
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-установка)
```
### Эмуляция GPIO сигналов:
```bash
python3 scripts/emulator.py
```
### Просмотр логов:
```bash
./scripts/view_logs.sh
```
---
## Камера (go2rtc)
Для работы камеры установите go2rtc:
```bash
# Запуск go2rtc
./scripts/go2rtc -config scripts/go2rtc.yaml
```
Камера будет доступна по адресу:
- MJPEG поток: `http://<IP>:1984/api/stream.mjpeg?src=cam_mjpeg`
- Встроенный интерфейс: `http://<IP>:1984`
---
Полный чек-лист (дашборд, звук, камера, «Нет связи») — [docs/operations.md](docs/operations.md#диагностика-проблем).

View File

@@ -14,7 +14,7 @@
- [Формат кадра](#формат-кадра)
- [Диагностика](#диагностика)
- [Устранение неполадок](#устранение-неполадок)
- [Лицензия](#лицензия)
- [Статус подпроекта](#статус-подпроекта)
## Возможности
@@ -55,16 +55,24 @@ sudo ldconfig
## Структура проекта
```
pluto/
SDR/
├── common.h # Общие определения и прототипы
├── common.c # Реализация общих функций
├── transmitter.c # Передатчик
├── receiver.c # Приёмник с частотной коррекцией
├── Makefile # Система сборки
├── 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
@@ -90,11 +98,11 @@ md5sum test_input.bin output.bin
# Сборка всего проекта
make
# Сборка только передатчика (если есть transmitter.c)
# Сборка только передатчика
make transmitter
# Сборка только приёмника (если есть receiver.c)
make receiver
# Приёмник — вручную, пока файл называется reciever.c (см. «Известная проблема» выше)
gcc -Wall -O2 -o receiver reciever.c common.c -liio -lliquid -lfftw3f -lm -lpthread
# Создание тестового файла 1 МБ
make test
@@ -266,7 +274,7 @@ ifconfig eth1
### Высокий EVM / много ошибок
1. **Уменьшите полосу**`-b 1500000` вместо `-b 3000000`
2. **Увеличьте усиление RX** — отредактируйте `RX_GAIN` в `receiver.c`
2. **Увеличьте усиление RX** — отредактируйте `RX_GAIN` в `reciever.c`
3. **Добавьте паузы**`-p 5000` для стабилизации
4. **Уменьшите амплитуду TX**`-a 0.15` для снижения искажений
5. **Проверьте CFO** — значение > 0.1 требует настройки частоты
@@ -276,7 +284,7 @@ ifconfig eth1
1. **Проверьте версию liquid-dsp** — требуется >= 1.3.0
2. **Пересоберите с отладкой**:
```bash
gcc -g -O0 -o receiver_debug receiver.c common.c -liio -lliquid -lfftw3f -lm -lpthread
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
@@ -314,7 +322,7 @@ cat data.bin | ./transmitter -f 1265000000 -r 3840000 -b 1500000 -g -10 -a 0.4 -
```
## Лицензия
## Статус подпроекта
Данный проект распространяется под лицензией MIT. Используйте на свой страх и риск.
SDR — автономный подпроект в репозитории GPIO Monitor: со своей сборкой, без связи с Go-сервером. Радиоканал используется как альтернативный транспорт байтов GPIO-шины (см. [scripts/emulatohackrf.sh](../scripts/emulatohackrf.sh) — передача потока эмулятора через HackRF). Общая документация основного проекта — в [docs/](../docs/).

View File

@@ -1,3 +1,7 @@
// gpio-monitor-server — HTTP-сервер мониторинга GPIO-шины Raspberry Pi.
// Читает поток байт из FIFO pipe, хранит их в кольцевом буфере и бинарных
// почасовых логах, отдаёт JSON API и встроенный веб-дашборд на :8080.
// Конфигурация — только флагами командной строки (см. main или docs/operations.md).
package main
import (
@@ -19,18 +23,21 @@ import (
"time"
"gpio-monitor/internal/adapter"
"gpio-monitor/internal/audio"
"gpio-monitor/internal/logger"
"gpio-monitor/internal/pipe"
)
// ===== EMBED WEB =====
//
//go:embed web/dist web/css web/*.html web/*.png
//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) {
@@ -38,7 +45,8 @@ func writeJSON(w http.ResponseWriter, v any) {
json.NewEncoder(w).Encode(v)
}
// HandleLogFiles - список доступных лог-файлов
// HandleLogFiles отдаёт список бинарных лог-файлов gpio-*.bin,
// отсортированный по времени изменения (новые первыми).
func (a *API) HandleLogFiles(w http.ResponseWriter, r *http.Request) {
dataDir, err := logger.GetDataLogsDir()
if err != nil {
@@ -104,7 +112,8 @@ func (a *API) HandleLogFiles(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
}
// HandleLogEvents - чтение событий с пагинацией
// HandleLogEvents отдаёт события из events_human.log с пагинацией
// (page, page_size; страница 1 — самые новые).
func (a *API) HandleLogEvents(w http.ResponseWriter, r *http.Request) {
// Параметры пагинации
page := 1
@@ -219,97 +228,190 @@ func (a *API) HandleLogEvents(w http.ResponseWriter, r *http.Request) {
}
// HandleLogData - чтение данных из конкретного bin файла
// 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
}
filename := r.URL.Query().Get("file")
if filename == "" {
http.Error(w, "отсутствует параметр file", 400)
return
}
// Защита от path traversal
filename = filepath.Base(filename)
dataDir, err := logger.GetDataLogsDir()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// Параметры пагинации
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
filePath := filepath.Join(dataDir, filename)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.Error(w, "файл не найден", 404)
return
}
pageSize := 100
if ps := r.URL.Query().Get("page_size"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 {
pageSize = parsed
}
}
// Читаем бинарный файл
f, err := os.Open(filePath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer f.Close()
// Защита от 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"`
Count byte `json:"count"`
Strength byte `json:"strength"`
StrengthName string `json:"strength_name"`
Amplitude byte `json:"amplitude"` // Старшие 2 бита (0-3)
Signal byte `json:"signal"` // Младшие 6 бит (0-63)
StrengthName string `json:"strength_name"`
}
var samples []Sample
// Сначала читаем все сэмплы
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
}
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]))
ts := int64(binary.LittleEndian.Uint64(buf[0:8]))
value := buf[8]
data := logger.ParseGPIO(value)
amplitude := (value >> 6) & 0x3
signal := value & 0x3F
samples = append(samples, Sample{
allSamples = append(allSamples, Sample{
Timestamp: ts,
Time: time.UnixMicro(ts).Format("02.01.2006 15:04:05"),
Time: time.UnixMicro(ts).Format("02.01.2006 15:04:05"),
Value: value,
Count: data.Count,
Strength: data.Strength,
StrengthName: logger.GetStrengthName(data.Strength),
Amplitude: (value >> 6) & 0x3, // Старшие 2 бита (0-3) - АМПЛИТУДА
Signal: value & 0x3F, // Младшие 6 бит (0-63) - СИГНАЛ
Amplitude: amplitude,
Signal: signal,
StrengthName: logger.GetStrengthName(amplitude),
})
}
}
// Ограничиваем количество точек для производительности
if len(samples) > 10000 {
step := len(samples) / 10000
var filtered []Sample
for i := 0; i < len(samples); i += step {
filtered = append(filtered, samples[i])
}
samples = filtered
}
total := len(allSamples)
writeJSON(w, map[string]any{
"filename": filename,
"total": len(samples),
"samples": samples,
})
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())
@@ -321,6 +423,11 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
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(),
@@ -330,9 +437,11 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
"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 {
@@ -346,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 {
@@ -359,6 +469,9 @@ func (a *API) HandleHistory(w http.ResponseWriter, r *http.Request) {
})
}
// 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")
@@ -374,7 +487,8 @@ func (a *API) HandleStream(w http.ResponseWriter, r *http.Request) {
})
}
// Прокси для MJPEG потока — просто ретранслирует готовый поток из go2rtc
// handleCamProxy — НЕ ИСПОЛЬЗУЕТСЯ: роутер вызывает handleCamProxyWithURL,
// этот вариант с захардкоженным URL остался как мёртвый код (docs/tech-debt.md).
func handleCamProxy(w http.ResponseWriter, r *http.Request) {
log.Printf("[cam] запрос от %s", r.RemoteAddr)
@@ -423,6 +537,8 @@ func handleCamProxy(w http.ResponseWriter, r *http.Request) {
}
}
// cors разрешает кросс-доменные GET-запросы с любых источников
// (рассчитано на доверенную локальную сеть).
func cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -550,6 +666,13 @@ func main() {
// 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())
@@ -558,8 +681,9 @@ func main() {
// ===== API И WEB СЕРВЕР =====
api := &API{
buf: buf,
startTime: time.Now(),
buf: buf,
startTime: time.Now(),
audioMonitor: audioMonitor,
}
// ===== API =====
@@ -599,7 +723,8 @@ func main() {
log.Fatal(http.ListenAndServe(*serverPort, nil))
}
// handleCamProxyWithURL проксирует MJPEG поток с указанным URL
// handleCamProxyWithURL проксирует MJPEG-поток с cameraURL (флаг -camera-url),
// ретранслируя заголовки и сбрасывая буфер после каждого чанка 32 КБ.
func handleCamProxyWithURL(w http.ResponseWriter, r *http.Request, cameraURL string) {
log.Printf("[cam] запрос от %s к %s", r.RemoteAddr, cameraURL)

View File

@@ -1,37 +1,56 @@
# GPIO Monitor Web Interface
Веб-интерфейс для мониторинга GPIO и видеопотока с камеры.
Веб-интерфейс дашборда GPIO Monitor: мониторинг в реальном времени, просмотр исторических логов, видеопоток камеры. Подробная документация фронтенда — [docs/frontend.md](../../../docs/frontend.md).
## Требования
- Node.js 20.x или выше
- npm 10.x или выше
- Go 1.21
Node.js нужен **только для компиляции TypeScript** — в рантайме фронтенд не имеет зависимостей и раздаётся Go-сервером из встроенных файлов.
### 1. Установка Node.js и npm на Raspberry Pi
#### Добавление официального репозитория NodeSource
```curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -```
#### Установка Node.js и npm
```sudo apt install -y nodejs```
#### Проверка установки
```node --version # v20.20.2```
```npm --version # 10.8.2```
### 2.Инициализация npm проекта
#### Переход в директорию с веб-файлами
```cd ~/temp/golang/cmd/server/web```
#### Инициализация package.json
```npm init -y```
#### Установка TypeScript и типов Node.js
```npm install -D typescript @types/node```
## Установка Node.js на Raspberry Pi
```bash
chmod +x node_modules/.bin/tsc
```
# Официальный репозиторий 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

@@ -1,4 +1,4 @@
/* Стили аудио-индикатора */
/* web/css/audio.css */
@keyframes pulse {
0%, 100% {
opacity: 1;
@@ -10,6 +10,7 @@
}
}
/* ===== АУДИО ИНДИКАТОР ===== */
#audio-indicator {
font-family: var(--font-tech);
font-size: var(--font-size-lg);
@@ -17,14 +18,231 @@
border-radius: 6px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #333;
min-width: 50px;
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;
}

View File

@@ -120,6 +120,33 @@ html {
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,
@@ -128,3 +155,4 @@ html {
width: 3px;
}
}

View File

@@ -1,21 +1,20 @@
/* Стили карточек (card, accordion, статусы) */
/* Стили карточек, каналов, статусов и истории */
/* ===== CARD ===== */
.card {
border: 1px solid #00ff88;
border-radius: 10px;
padding: 14px;
background: rgba(0,255,136,0.05);
overflow: hidden;
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);
@@ -24,7 +23,7 @@
font-weight: var(--font-weight-bold);
}
/* ===== STATUS ===== */
/* ===== СТАТУС ===== */
#status {
font-family: var(--font-tech);
font-size: var(--font-size-3xl);
@@ -32,23 +31,19 @@
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; }
@@ -62,7 +57,7 @@
letter-spacing: 0.02em;
}
/* ===== ACCORDION ===== */
/* ===== АККОРДЕОН ===== */
.accordion-header {
cursor: pointer;
user-select: none;
@@ -73,23 +68,47 @@
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);
@@ -99,28 +118,359 @@
text-shadow: 0 0 20px rgba(0, 255, 136, 0.2);
}
/* ===== HISTORY ===== */
#history {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.35;
max-height: 420px;
overflow-y: auto;
white-space: nowrap;
padding-right: 4px;
/* ===== КАНАЛЫ ===== */
.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;
}
#history::-webkit-scrollbar {
width: 6px;
/* ===== ОСНОВНЫЕ ПРОГРЕСС-БАРЫ (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;
}
#history::-webkit-scrollbar-track {
background: rgba(0, 255, 136, 0.05);
/* ===== ЗВУК И ВЫСОТА ===== */
.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);
}
#history::-webkit-scrollbar-thumb {
background: #00ff88;
border-radius: 3px;
/* ===== ИНДИКАТОРЫ СИГНАЛА ===== */
.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

@@ -3,58 +3,56 @@
/* JetBrains Mono */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
src: url('/fonts/JetBrainsMono/ttf/JetBrainsMono-Regular.ttf') format('truetype');
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
src: url('/fonts/JetBrainsMono/ttf/JetBrainsMono-Bold.ttf') format('truetype');
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
src: url('/fonts/JetBrainsMono/ttf/JetBrainsMono-ExtraBold.ttf') format('truetype');
font-style: normal;
font-display: swap;
}
/* Orbitron - для заголовков */
/* Orbitron - используем существующий файл */
@font-face {
font-family: 'Orbitron';
font-style: normal;
font-weight: 400;
src: url('/fonts/Orbitron/static/Orbitron-Regular.ttf') format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Orbitron';
font-style: normal;
font-weight: 600;
src: url('/fonts/Orbitron/static/Orbitron-SemiBold.ttf') format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Orbitron';
font-style: normal;
src: url('/fonts/orbitron-bold-webfont.woff') format('woff');
font-weight: 700;
src: url('/fonts/Orbitron/static/Orbitron-Bold.ttf') format('truetype');
font-style: normal;
font-display: swap;
}
/* Share Tech Mono - для статусов и значений */
/* Share Tech Mono */
@font-face {
font-family: 'Share Tech Mono';
font-style: normal;
font-weight: 400;
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

@@ -5,23 +5,28 @@
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 */
.left {
flex: 1 1 auto;
min-width: 300px;
padding: 10px;
padding: 10px 10%; /* ← X% от ширины экрана */
overflow-y: auto;
overflow-x: hidden;
height: 100%;
overscroll-behavior: auto;
}
/* RIGHT */
.right {
flex: 0 0 40%;
min-width: 0;
@@ -29,7 +34,6 @@
position: relative;
overflow: hidden;
}
.right.hidden {
flex: 0 0 0 !important;
width: 0 !important;
@@ -38,7 +42,6 @@
pointer-events: none;
}
/* ===== RESIZER ===== */
.resizer {
width: 8px;
min-width: 8px;
@@ -48,11 +51,9 @@
flex-shrink: 0;
z-index: 10;
}
.resizer:hover {
opacity: 0.8;
}
.resizer.hidden {
width: 0 !important;
min-width: 0 !important;
@@ -60,17 +61,106 @@
pointer-events: none;
}
/* ===== GRID ===== */
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
align-items: stretch;
max-width: 1200px;
margin: 0 auto;
}
@media (min-width: 900px) {
/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */
@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;

View File

@@ -545,4 +545,105 @@
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

@@ -1,119 +1,63 @@
/* ===== РЕСПОНСИВНЫЙ ДИЗАЙН ===== */
/* ============================================
ПЛАНШЕТЫ (768px - 1024px)
============================================ */
/* ===== ПЛАНШЕТЫ ===== */
@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;
}
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; }
}
/* ============================================
МОБИЛЬНЫЕ УСТРОЙСТВА (до 768px)
============================================ */
/* ===== МОБИЛЬНЫЕ ===== */
@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 {
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;
@@ -121,8 +65,6 @@
flex: 1;
min-width: 80px;
}
/* Ссылка "Журнал" */
.header-bar a[href="/logs.html"] {
font-size: var(--font-size-sm);
padding: 6px 12px;
@@ -130,438 +72,125 @@
flex: 1;
min-width: 60px;
}
/* ===== МАКЕТ ===== */
.layout {
flex-direction: column;
height: auto;
gap: 8px;
}
.panel {
height: auto;
overflow-y: visible;
}
.left {
flex: 1 1 auto !important;
min-width: 0 !important;
padding: 0;
width: 100% !important;
}
.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;
}
.right.hidden {
display: none !important;
flex: 0 0 0 !important;
height: 0 !important;
min-height: 0 !important;
}
.resizer {
display: none !important;
}
/* ===== СЕТКА ===== */
.grid {
grid-template-columns: 1fr;
gap: 8px;
}
.wide {
grid-column: span 1;
}
/* ===== КАРТОЧКИ ===== */
.card {
padding: 12px;
border-radius: 8px;
}
.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;
}
#history {
font-size: var(--font-size-sm);
max-height: 300px;
line-height: 1.5;
}
/* ===== КАМЕРА ===== */
.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;
}
.hotkey-hint kbd {
padding: 2px 6px;
font-size: var(--font-size-xs);
}
.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; }
}
/* ============================================
МАЛЕНЬКИЕ ТЕЛЕФОНЫ (до 480px)
============================================ */
/* ===== МАЛЕНЬКИЕ ТЕЛЕФОНЫ ===== */
@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;
}
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;
}
.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; }
}
/* ============================================
ОРИЕНТАЦИЯ (ALBUM / PORTRAIT)
============================================ */
@media (max-width: 768px) and (orientation: landscape) {
.layout {
flex-direction: row;
height: calc(100vh - 80px);
}
.left {
flex: 1 1 60% !important;
min-width: 0 !important;
padding-right: 8px;
overflow-y: auto;
height: 100%;
}
.right {
flex: 0 0 40% !important;
min-width: 0 !important;
height: 100%;
min-height: 0;
}
.grid {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.wide {
grid-column: span 2;
}
#status {
font-size: var(--font-size-xl);
}
}
/* ============================================
УЛУЧШЕНИЯ ДЛЯ ТАЧ-ИНТЕРФЕЙСА
============================================ */
/* ===== ТАЧ-ИНТЕРФЕЙС ===== */
@media (hover: none) and (pointer: coarse) {
/* Увеличиваем область касания */
button,
.cam-btn,
.tab-btn,
.pagination-btn,
.log-file-item,
.accordion-header,
.pagination-goto-btn {
button, .cam-btn, .tab-btn, .pagination-btn, .log-file-item,
.accordion-header, .pagination-goto-btn {
min-height: 44px;
min-width: 44px;
padding: 8px 16px;
}
/* Убираем hover-эффекты, оставляем active */
.cam-btn:hover,
.tab-btn:hover,
.pagination-btn:hover,
.log-file-item:hover {
.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 {
.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 {
.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;
}
.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;
}
body { background: #0a0f0a; }
}
/* ============================================
ПЕЧАТЬ
============================================ */
/* ===== ПЕЧАТЬ ===== */
@media print {
.header-bar,
.cam-btn,
.resizer,
.right,
.pagination-container,
.hotkey-hint {
.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;
}
}
/* ===== ДОПОЛНИТЕЛЬНЫЕ УЛУЧШЕНИЯ ДЛЯ ХЕДЕРА ===== */
@media (max-width: 768px) {
.header-bar {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 8px !important;
}
.header-controls {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
gap: 6px !important;
}
.layout { display: block !important; height: auto !important; }
.panel { height: auto !important; overflow: visible !important; }
.card { break-inside: avoid; border: 1px solid #00ff88; }
}
@media (max-width: 480px) {
.header-controls {
gap: 4px !important;
}
/* ===== СКРОЛЛБАРЫ ===== */
::-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;
}
/* ===== УЛУЧШЕНИЯ ДЛЯ ТАЧ-ИНТЕРФЕЙСА ===== */
@media (hover: none) and (pointer: coarse) {
.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);
}
.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;
}
/* ===== КНОПКА ЗАКРЫТИЯ КАМЕРЫ НА МОБИЛЬНЫХ ===== */
@media (max-width: 768px) {
.cam-close-btn {
font-size: var(--font-size-lg);
padding: 6px 14px;
min-height: 44px;
min-width: 44px;
}
.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

@@ -7,13 +7,9 @@
<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>
<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">
@@ -23,71 +19,91 @@
<link rel="stylesheet" href="/css/audio.css">
<link rel="stylesheet" href="/css/responsive.css">
</head>
<body>
<div class="header-bar">
<h1>ЗВЕЗДНЫЙ ГРИД</h1>
<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 id="audio-indicator" class="audio-indicator">🔇</div>
<button id="camToggle" class="cam-btn">КАМЕРА</button>
</div>
</div>
<!-- Остальной код без изменений -->
<div class="layout">
<!-- LEFT: графики / дашборд -->
<!-- LEFT -->
<div class="panel left" id="leftPanel">
<div class="grid">
<div class="card wide">
<h3>📊 СОСТОЯНИЕ</h3>
<!-- Карточка состояния -->
<div class="card wide" style="flex-shrink: 0;">
<h3>СОСТОЯНИЕ</h3>
<div id="status">...</div>
<div id="meta"></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>
<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>
</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="progress-row">
<div class="progress-label">🔊 Звук</div>
<div class="progress">
<div class="fill" id="bar2"></div>
</div>
</div>
<div class="progress-row">
<div class="progress-label">📐 Высота</div>
<div class="progress">
<div class="fill" id="bar3"></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>
@@ -98,13 +114,11 @@
<div class="panel right hidden" id="rightPanel">
<div class="cam-modal">
<div class="cam-header">
<span>📹 Видео с камеры</span>
<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>
@@ -113,15 +127,12 @@
</div>
</div>
</div>
</div>
<!-- Подсказка по горячим клавишам -->
<div class="hotkey-hint">
<kbd>C</kbd> — камера &nbsp;|&nbsp; <kbd>F</kbd> — полный экран &nbsp;|&nbsp; <kbd>M</kbd> — звук &nbsp;|&nbsp; <kbd>Esc</kbd>сброс
(ENG lang key) <kbd>C</kbd> — камера &nbsp;|&nbsp; <kbd>M</kbd> — звук &nbsp;|&nbsp; <kbd>Esc</kbd>сброс
</div>
<script type="module" src="/dist/app.js"></script>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,25 +1,30 @@
// app.js (оркестратор)
// web/src/app.ts - Оркестратор приложения
import {
fetchHealth,
fetchHistory
fetchHealth,
fetchHistory
} from "./data.js";
import {
state,
addSignals,
resetBufferTracking,
updatePeakHolder,
updateSignalPeakHolder,
setPeakHolderListener
state,
addSignals,
resetBufferTracking,
updatePeakHolder,
updateSignalPeakHolder,
setPeakHolderListener
} from "./state.js";
import {
initDOM,
setServerTime,
setStatus,
setMeta,
setBars,
setSignal,
setAudioIndicator,
DOM
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";
@@ -34,237 +39,396 @@ 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();
initDOM();
initAccordion();
initResize();
initCamera();
initVolumeControl();
// Инициализация звука при первом клике
document.addEventListener('click', () => {
audioEngine.updateLevel(0);
}, { once: true });
document.addEventListener('click', () => {
audioEngine.updateLevel(0);
}, { once: true });
setPeakHolderListener(() => {
if (!connectionLost) {
setBars(state.peakValue);
setSignal(state.peakSignalLevel);
setPeakHolderListener(() => {
if (!connectionLost) {
setBars(state.peakValue);
setSignal(state.peakSignalLevel);
const signalLevel = state.peakSignalLevel;
if (signalLevel !== lastSignalLevel) {
lastSignalLevel = signalLevel;
audioEngine.updateLevel(signalLevel);
const signalLevel = state.peakSignalLevel;
const currentVolume = audioEngine.getVolume();
// Обновляем индикатор звука
const isActive = signalLevel > 0;
setAudioIndicator(isActive, signalLevel);
}
}
});
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(); // Эмулируем нажатие кнопки переключения
toggleBtn.click();
}
});
}
});
document.addEventListener("keydown", (e: KeyboardEvent) => {
const leftPanel = document.getElementById("leftPanel");
const rightPanel = document.getElementById("rightPanel");
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':
// Toggle звука
if (lastSignalLevel > 0) {
// Временно отключаем
audioEngine.updateLevel(0);
setAudioIndicator(false);
console.log('[Audio] Звук отключён (M)');
// Сохраняем уровень для восстановления
window._mutedLevel = lastSignalLevel;
lastSignalLevel = 0;
} else {
// Восстанавливаем уровень
const level = window._mutedLevel || state.peakSignalLevel;
if (level > 0) {
audioEngine.updateLevel(level);
lastSignalLevel = level;
setAudioIndicator(true, level);
console.log('[Audio] Звук включён (M)');
}
}
break;
}
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()
]);
try {
const [health, history] = await Promise.all([
fetchHealth(),
fetchHistory()
]);
consecutiveErrors = 0;
consecutiveErrors = 0;
if (health.server_time) {
setServerTime(health.server_time);
}
if (connectionLost) {
console.log("[app] Соединение восстановлено");
}
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);
}
const value = raw & 0x3F;
const level = (raw >> 6) & 0x3;
const percent = (value / 63) * 100;
if (isOnline) {
const displayValue = updatePeakHolder(percent);
setBars(displayValue);
const displaySignalLevel = updateSignalPeakHolder(level);
setSignal(displaySignalLevel);
const currentSignalLevel = state.peakSignalLevel;
if (currentSignalLevel !== lastSignalLevel) {
lastSignalLevel = currentSignalLevel;
audioEngine.updateLevel(currentSignalLevel);
setAudioIndicator(currentSignalLevel > 0, currentSignalLevel);
if (health.server_time) {
setServerTime(health.server_time);
}
} else {
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);
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);
}
}
if (DOM.lastByte) {
DOM.lastByte.textContent = isOnline ? value.toString() : "--";
}
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) {
DOM.history.innerHTML = arr
.map((b, i) => `[${i}] ${(b & 0xFF).toString(2).padStart(8, "0")} = ${b & 0xFF}`)
.join("<br>");
}
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);
setServerTime("--:--:--");
setBars(0, true);
setSignal(0, true);
resetBufferTracking();
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
if (DOM.lastByte) DOM.lastByte.textContent = "--";
if (consecutiveErrors >= MAX_ERRORS && pollTimer) {
clearInterval(pollTimer);
pollTimer = window.setInterval(update, 2000);
console.log("[app] Замедлили опрос до 2с");
}
}
}
function startPolling(): void {
if (pollTimer) clearInterval(pollTimer);
if (consecutiveErrors > 0) update();
pollTimer = window.setInterval(update, 500);
if (pollTimer) clearInterval(pollTimer);
if (consecutiveErrors > 0) update();
pollTimer = window.setInterval(update, 500);
}
window.addEventListener("focus", () => {
if (consecutiveErrors >= MAX_ERRORS) {
console.log("[app] Окно в фокусе, ускоряем опрос");
consecutiveErrors = 0;
startPolling();
}
if (consecutiveErrors >= MAX_ERRORS) {
consecutiveErrors = 0;
startPolling();
}
});
window.addEventListener("beforeunload", () => {
audioEngine.dispose();
audioEngine.dispose();
});
declare global {
interface Window {
_mutedLevel?: number;
}
interface Window {
_mutedLevel?: number;
}
}
startPolling();

View File

@@ -1,4 +1,4 @@
// audio.ts - Звуковой движок для сигнала
// web/src/audio.ts - Звуковой движок для сигнала
export class AudioEngine {
private audioContext: AudioContext | null = null;
@@ -8,20 +8,73 @@ export class AudioEngine {
private currentLevel: number = 0;
private currentFrequency: number = 800;
private fadeTimeout: number | null = null;
private readonly FADE_TIME: number = 0.05; // 50 мс для плавного старта/остановки
private readonly FADE_TIME: number = 0.05;
private readonly FREQUENCY_MAP: Record<number, number> = {
1: 800, // Уровень 1 → 800 Гц
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() {
// Инициализация при первом использовании
}
/**
* Инициализация AudioContext (требует взаимодействия пользователя)
* Установка громкости (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) {
@@ -57,16 +110,13 @@ export class AudioEngine {
if (!this.oscillator) return;
try {
// Плавное изменение частоты за 50 мс
const currentTime = this.audioContext?.currentTime || 0;
this.oscillator.frequency.exponentialRampToValueAtTime(
frequency,
currentTime + this.FADE_TIME
);
this.currentFrequency = frequency;
console.log(`[Audio] Частота: ${frequency} Гц`);
} catch (error) {
// Если не удалось плавно, устанавливаем сразу
this.oscillator.frequency.value = frequency;
this.currentFrequency = frequency;
}
@@ -78,30 +128,26 @@ export class AudioEngine {
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.gainNode.gain.value = 0;
// Подключаем: осциллятор → усилитель → выход
this.oscillator.connect(this.gainNode);
this.gainNode.connect(this.audioContext.destination);
// Запускаем осциллятор
this.oscillator.start();
this.isPlaying = true;
console.log(`[Audio] Звук создан, частота: ${frequency} Гц, уровень: ${level}`);
// Применяем текущую громкость
this.updateVolume();
}
/**
@@ -109,18 +155,14 @@ export class AudioEngine {
* @param level - уровень сигнала (0-3)
*/
public async updateLevel(level: number): Promise<void> {
// Инициализация при первом вызове
if (!this.audioContext) {
const initialized = await this.initAudio();
if (!initialized) {
return;
}
if (!initialized) return;
}
const context = this.audioContext;
if (!context) return;
// Если AudioContext не готов, пробуем восстановить
if (context.state !== 'running') {
try {
await context.resume();
@@ -130,83 +172,42 @@ export class AudioEngine {
}
}
// Приводим уровень к числу
const newLevel = Math.max(0, Math.min(3, Math.round(level)));
// Если уровень не изменился — ничего не делаем
if (newLevel === this.currentLevel) {
return;
}
const oldLevel = this.currentLevel;
this.currentLevel = newLevel;
// Получаем частоту для нового уровня
const newFrequency = this.getFrequencyForLevel(newLevel);
// Если уровень > 0 и звук не создан — создаём
if (newLevel > 0 && !this.oscillator) {
this.createSoundGraph(newLevel);
// Устанавливаем громкость после создания
this.setVolume(newLevel);
// Если громкость 0 - ничего не включаем
if (this.volume === 0) {
this.stopSound();
return;
}
const newFrequency = this.getFrequencyForLevel(newLevel);
if (newLevel > 0 && !this.oscillator) {
this.createSoundGraph(newLevel);
return;
}
// Если уровень 0 — выключаем звук
if (newLevel === 0) {
this.fadeOut();
return;
}
// Если звук не играет — включаем заново
if (!this.isPlaying || !this.oscillator) {
this.createSoundGraph(newLevel);
this.setVolume(newLevel);
return;
}
// Изменяем частоту, если она изменилась
if (newFrequency !== this.currentFrequency) {
this.updateFrequency(newFrequency);
}
// Обновляем громкость
this.setVolume(newLevel);
console.log(`[Audio] Уровень: ${oldLevel}${newLevel}, Частота: ${newFrequency} Гц`);
}
/**
* Установка громкости для уровня
*/
private setVolume(level: number): void {
if (!this.gainNode || !this.audioContext) return;
// Расчёт громкости: уровень 1 = 10%, 2 = 25%, 3 = 45%
const volumeMap: Record<number, number> = {
1: 0.10,
2: 0.25,
3: 0.45
};
const targetVolume = volumeMap[level] || 0;
try {
const currentGain = this.gainNode.gain.value;
// Если звук выключен или очень тихий, включаем сразу
if (currentGain < 0.01) {
this.gainNode.gain.setValueAtTime(targetVolume, this.audioContext.currentTime);
} else {
// Плавное изменение за 50 мс
this.gainNode.gain.exponentialRampToValueAtTime(
Math.max(targetVolume, 0.001),
this.audioContext.currentTime + this.FADE_TIME
);
}
} catch (error) {
// fallback
this.gainNode.gain.value = targetVolume;
}
this.updateVolume();
}
/**
@@ -215,28 +216,22 @@ export class AudioEngine {
private fadeOut(): void {
if (!this.gainNode || !this.audioContext) return;
// Отменяем старый таймер
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
try {
// Плавно убавляем громкость за 100 мс
const currentTime = this.audioContext.currentTime;
this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2);
} catch (error) {
// fallback
this.gainNode.gain.value = 0;
}
// Останавливаем звук после затухания
this.fadeTimeout = window.setTimeout(() => {
this.stopSound();
this.fadeTimeout = null;
}, this.FADE_TIME * 2 * 1000 + 50);
console.log('[Audio] Затухание...');
}
/**
@@ -247,18 +242,14 @@ export class AudioEngine {
try {
this.oscillator.stop();
this.oscillator.disconnect();
} catch (e) {
// Игнорируем ошибки при остановке
}
} catch (e) {}
this.oscillator = null;
}
if (this.gainNode) {
try {
this.gainNode.disconnect();
} catch (e) {
// Игнорируем ошибки
}
} catch (e) {}
this.gainNode = null;
}
@@ -268,8 +259,6 @@ export class AudioEngine {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
console.log('[Audio] Звук остановлен');
}
// ============================================
@@ -279,15 +268,13 @@ export class AudioEngine {
/**
* Проверка, активен ли звук
*/
// noinspection JSUnusedGlobalSymbols
public isActive(): boolean {
return this.isPlaying && this.currentLevel > 0;
return this.isPlaying && this.currentLevel > 0 && this.volume > 0;
}
/**
* Получение текущей частоты
*/
// noinspection JSUnusedGlobalSymbols
public getCurrentFrequency(): number {
return this.currentFrequency;
}
@@ -295,7 +282,6 @@ export class AudioEngine {
/**
* Получение текущего уровня
*/
// noinspection JSUnusedGlobalSymbols
public getCurrentLevel(): number {
return this.currentLevel;
}
@@ -303,23 +289,19 @@ export class AudioEngine {
/**
* Полная остановка и освобождение ресурсов
*/
// noinspection JSUnusedGlobalSymbols
public async dispose(): Promise<void> {
this.stopSound();
if (this.audioContext) {
try {
await this.audioContext.close();
} catch (e) {
// Игнорируем
}
} catch (e) {}
this.audioContext = null;
}
this.currentLevel = 0;
this.currentFrequency = 800;
this.volume = 0;
console.log('[Audio] Ресурсы освобождены');
}
}
// noinspection JSUnusedGlobalSymbols
export const audioEngine = new AudioEngine();

View File

@@ -41,7 +41,7 @@ export function initCamera(): void {
if (textSpan) {
textSpan.textContent = 'Скрыть';
} else {
btn.innerHTML = '📷 Скрыть';
btn.innerHTML = 'Скрыть';
}
}
@@ -74,7 +74,7 @@ export function initCamera(): void {
if (textSpan) {
textSpan.textContent = 'Камера';
} else {
btn.innerHTML = '📷 Камера';
btn.innerHTML = 'Камера';
}
}

View File

@@ -8,6 +8,7 @@ interface HealthResponse {
has_data: boolean;
latest: number;
server_time?: string;
sound_level?: number;
stats: {
filled: number;
bytes_per_sec: number;

View File

@@ -21,17 +21,28 @@ interface LogSample {
ts: number;
time: string;
value: number;
count: number;
strength: number;
strength_name: string;
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 {
@@ -64,6 +75,10 @@ interface AppState {
currentPage: number;
totalPages: number;
isLoading: boolean;
// Для данных
dataPage: number;
dataTotalPages: number;
isLoadingData: boolean;
}
const state: AppState = {
@@ -73,9 +88,13 @@ const state: AppState = {
currentPage: 1,
totalPages: 1,
isLoading: false,
dataPage: 1,
dataTotalPages: 1,
isLoadingData: false,
};
const PAGE_SIZE = 100;
const DATA_PAGE_SIZE = 100;
// ============================================
// DOM ЭЛЕМЕНТЫ
@@ -89,7 +108,20 @@ const DOM = {
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,
@@ -99,6 +131,29 @@ const DOM = {
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';
}
}
// ============================================
// ФУНКЦИИ ЗАГРУЗКИ ДАННЫХ
// ============================================
@@ -125,14 +180,16 @@ async function loadFileList(): Promise<void> {
<div class="file-info">${file.time} | ${(file.size / 1024).toFixed(1)} KB</div>
`;
div.addEventListener('click', () => {
void loadFileData(file.name);
state.dataPage = 1;
void loadFileData(file.name, 1);
});
DOM.fileList?.appendChild(div);
});
// Автоматически загружаем первый файл
if (files.length > 0 && !state.currentFile) {
await loadFileData(files[0].name);
state.dataPage = 1;
await loadFileData(files[0].name, 1);
}
} catch (error) {
console.error('[Logs] Ошибка загрузки списка файлов:', error);
@@ -140,19 +197,20 @@ async function loadFileList(): Promise<void> {
}
/**
* Загрузка данных из файла
* Загрузка данных из файла с пагинацией
*/
async function loadFileData(filename: string): Promise<void> {
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 для преобразования NodeList в массив
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;
@@ -163,13 +221,49 @@ async function loadFileData(filename: string): Promise<void> {
}
try {
const response = await fetch(`/api/log/data?file=${encodeURIComponent(filename)}`);
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;
}
}
@@ -183,33 +277,37 @@ async function loadEvents(page: number = 1): Promise<void> {
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} (новые сверху)`);
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}`);
console.log(`[Logs] Порядок: ${data.order || 'newest_first'}, Показаны записи ${data.start_index}-${data.end_index} из ${data.total}`);
} catch (error) {
console.error('[Logs] Ошибка загрузки событий:', error);
if (DOM.eventsContent) {
DOM.eventsContent.innerHTML = '<div style="color:#ff4444;">❌ Ошибка загрузки событий</div>';
DOM.eventsContent.innerHTML = `
<div class="empty-state">
<div class="empty-message">Нет событий</div>
<div class="empty-hint">Журнал событий пуст</div>
</div>
`;
}
} finally {
state.isLoading = false;
@@ -227,34 +325,26 @@ function renderData(data: LogDataResponse): void {
const samples = data.samples || [];
// Статистика
if (samples.length > 0 && DOM.logStats) {
const totalPoints = samples.length;
const amplitudeOver0Count = samples.filter((s: LogSample) => s.amplitude > 0).length;
const signalOver10Count = samples.filter((s: LogSample) => s.signal > 10).length;
const totalObjects = samples.reduce((sum: number, s: LogSample) => sum + s.signal, 0);
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">${totalPoints}</div>
<div class="value">${stats.total_points}</div>
<div class="label">Всего точек</div>
</div>
<div class="log-stat-card">
<div class="value">${amplitudeOver0Count}</div>
<div class="value">${stats.amplitude_over_0}</div>
<div class="label">Амплитуда > 0</div>
</div>
<div class="log-stat-card">
<div class="value">${signalOver10Count}</div>
<div class="value">${stats.signal_over_10}</div>
<div class="label">Сигнал > 10</div>
</div>
<div class="log-stat-card">
<div class="value">${totalObjects}</div>
<div class="label">Объектов всего</div>
</div>
`;
}
// График
if (DOM.logChart && samples.length > 0) {
if (DOM.logChart && data.total > 0) {
const history = samples.map((s: LogSample) => s.signal);
drawChart(DOM.logChart, history);
}
@@ -264,6 +354,7 @@ function renderData(data: LogDataResponse): void {
let html = `<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Время</th>
<th>Амплитуда</th>
<th>Сигнал</th>
@@ -271,15 +362,17 @@ function renderData(data: LogDataResponse): void {
</thead>
<tbody>`;
const displaySamples = samples.slice(-200);
displaySamples.forEach((s: LogSample) => {
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>
@@ -289,7 +382,12 @@ function renderData(data: LogDataResponse): void {
html += '</tbody></table>';
DOM.dataTableContainer.innerHTML = html;
} else if (DOM.dataTableContainer) {
DOM.dataTableContainer.innerHTML = '<p>Нет данных</p>';
DOM.dataTableContainer.innerHTML = `
<div class="empty-state">
<div class="empty-message">Нет данных</div>
<div class="empty-hint">Выберите файл с данными</div>
</div>
`;
}
}
@@ -301,70 +399,121 @@ function renderEvents(data: EventsResponse): void {
let html = '';
if (data.events.length === 0) {
html = '<div style="color:#666;">Нет событий</div>';
html = `
<div class="empty-state">
<div class="empty-message">Нет событий</div>
<div class="empty-hint">Журнал событий пуст</div>
</div>
`;
} else {
data.events.forEach((e: Event) => {
html += `<div><span class="event-time">[${e.time}]</span> ${e.event}</div>`;
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 {
const orderText = data.order === 'newest_first' ? 'новые сверху' : 'старые сверху';
DOM.pageInfo.textContent = `Страница ${page} из ${total_pages} (записи ${start_index}-${end_index} из ${total}) ${orderText}`;
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); // Первая страница = самые новые
goToPage(1);
}
function goToLastPage(): void {
goToPage(state.totalPages); // Последняя страница = самые старые
goToPage(state.totalPages);
}
// "◀ Предыдущая" → страница с номером на 1 меньше (более новые записи)
function goToPreviousPage(): void {
goToPage(state.currentPage - 1);
}
// "Следующая ▶" → страница с номером на 1 больше (более старые записи)
function goToNextPage(): void {
goToPage(state.currentPage + 1);
}
@@ -381,7 +530,6 @@ async function updateTime(): Promise<void> {
DOM.serverTime.textContent = data.server_time || new Date().toLocaleTimeString();
}
} catch (error) {
// Если сервер недоступен, используем локальное время
if (DOM.serverTime) {
DOM.serverTime.textContent = new Date().toLocaleTimeString();
}
@@ -392,39 +540,97 @@ async function updateTime(): Promise<void> {
// ИНИЦИАЛИЗАЦИЯ
// ============================================
function initPaginationControls(): void {
// Создаём элементы пагинации, если их нет
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('paginationContainer');
let paginationContainer = document.getElementById('eventsPaginationContainer');
if (!paginationContainer) {
const eventsContent = document.getElementById('eventsContent');
if (!eventsContent) return;
paginationContainer = document.createElement('div');
paginationContainer.id = 'paginationContainer';
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>
<span id="pageInfo" class="pagination-info">Страница 1 из 1</span>
<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>
`;
// Вставляем перед содержимым событий
const eventsContent = document.getElementById('eventsContent');
if (eventsContent) {
eventsTab.insertBefore(paginationContainer, eventsContent);
}
eventsTab.insertBefore(paginationContainer, eventsContent);
}
// Обновляем ссылки на DOM элементы
DOM.firstPageBtn = document.getElementById('firstPage') as HTMLButtonElement | null;
DOM.prevPageBtn = document.getElementById('prevPage') as HTMLButtonElement | null;
DOM.nextPageBtn = document.getElementById('nextPage') as HTMLButtonElement | null;
@@ -433,7 +639,6 @@ function initPaginationControls(): void {
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);
@@ -463,7 +668,6 @@ 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');
@@ -474,8 +678,24 @@ function initTabs(): void {
const targetTab = document.getElementById(`tab-${tabName}`);
if (targetTab) targetTab.classList.add('active');
if (tabName === 'events') {
// Загружаем первую страницу событий
// ===== УПРАВЛЕНИЕ БОКОВОЙ ПАНЕЛЬЮ =====
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);
}
@@ -487,25 +707,29 @@ function initTabs(): void {
async function init(): Promise<void> {
console.log('[Logs] Инициализация...');
// Инициализация пагинации
initPaginationControls();
// По умолчанию показываем боковую панель
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] Инициализация завершена');
@@ -515,7 +739,6 @@ async function init(): Promise<void> {
// ЗАПУСК
// ============================================
// Запускаем инициализацию после загрузки DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
void init();

View File

@@ -1,23 +1,26 @@
// state.ts - Peak Holder алгоритм с таймером
// state.ts - Peak Holder с фиксированным временем удержания
interface AppState {
maxPoints: number;
signalHistory: number[];
lastBufferSize: number;
// Peak Holder
// Peak Holder для амплитуды
peakValue: number; // Текущее удерживаемое пиковое значение амплитуды
currentValue: 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;
@@ -30,14 +33,17 @@ export const state: AppState = {
peakValue: 0,
currentValue: 0,
peakTimer: null,
peakHoldTime: 3000, // 3 секунды
peakHoldTime: 3000,
peakLocked: false,
peakSignalLevel: 0,
currentSignalLevel: 0,
signalPeakTimer: null,
signalPeakHoldTime: 3000,
signalPeakLocked: false,
hasPeak: false,
lastMaxSignal: 0,
};
let peakHolderListener: PeakHolderListener | null = null;
@@ -46,20 +52,111 @@ let peakHolderListener: PeakHolderListener | null = null;
// ПУБЛИЧНЫЕ ФУНКЦИИ
// ============================================
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
peakHolderListener = listener;
}
function notifyPeakHolderChange(): void {
if (peakHolderListener) {
peakHolderListener();
}
}
/**
* Установка времени удержания пика сигнала
* @param ms - время в миллисекундах
* Обновление пикового значения амплитуды
* @param currentMax - текущий максимум из буфера (в процентах 0-100)
* @returns пиковое значение
*/
// noinspection JSUnusedGlobalSymbols
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;
}
/**
* Получение информации о пике сигнала
* @returns { peak: number; current: number; timerActive: boolean }
*/
// noinspection JSUnusedGlobalSymbols
export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } {
return {
peak: state.peakSignalLevel,
@@ -68,11 +165,6 @@ export function getSignalPeakInfo(): { peak: number; current: number; timerActiv
};
}
/**
* Добавление нового значения сигнала в историю
* @param value - значение сигнала
*/
// noinspection JSUnusedGlobalSymbols
export function addSignal(value: number): void {
state.signalHistory.push(value);
if (state.signalHistory.length > state.maxPoints) {
@@ -80,65 +172,6 @@ export function addSignal(value: number): void {
}
}
/**
* Установка слушателя для обновления пика
* @param listener - функция-слушатель
*/
// noinspection JSUnusedGlobalSymbols
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
peakHolderListener = listener;
}
/**
* Обновление пикового значения амплитуды
* @param target - текущее значение
* @returns пиковое значение
*/
// noinspection JSUnusedGlobalSymbols
export function updatePeakHolder(target: number): number {
// Обновляем текущее значение
state.currentValue = target;
// Если новое значение больше текущего пика
if (target > state.peakValue) {
// Устанавливаем новый пик
state.peakValue = target;
state.hasPeak = true;
scheduleAmplitudePeakRelease();
} else if (target < state.peakValue && state.peakTimer === null) {
// Пик выше текущего значения, но таймер пропал — перезапускаем удержание
scheduleAmplitudePeakRelease();
} // Возвращаем текущий пик
return state.peakValue;
}
/**
* Обновление пикового уровня сигнала
* @param level - текущий уровень
* @returns пиковый уровень
*/
// noinspection JSUnusedGlobalSymbols
export function updateSignalPeakHolder(level: number): number {
// Обновляем текущее значение
state.currentSignalLevel = level;
// Если новый уровень больше пика — устанавливаем новый пик
if (level > state.peakSignalLevel) {
state.peakSignalLevel = level;
state.hasPeak = true;
scheduleSignalPeakRelease();
} else if (level < state.peakSignalLevel && state.signalPeakTimer === null) {
scheduleSignalPeakRelease();
}
// Возвращаем текущий пик
return state.peakSignalLevel;
}
/**
* Принудительный сброс пика сигнала
*/
// noinspection JSUnusedGlobalSymbols
export function forceResetSignalPeak(): void {
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
@@ -147,12 +180,11 @@ export function forceResetSignalPeak(): void {
state.peakSignalLevel = 0;
state.currentSignalLevel = 0;
state.hasPeak = false;
state.lastMaxSignal = 0;
state.signalPeakLocked = false;
notifyPeakHolderChange();
}
/**
* Принудительный сброс всех пиков
*/
// noinspection JSUnusedGlobalSymbols
export function forceResetPeak(): void {
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
@@ -161,15 +193,12 @@ export function forceResetPeak(): void {
state.peakValue = 0;
state.currentValue = 0;
state.hasPeak = false;
state.lastMaxSignal = 0;
state.peakLocked = false;
forceResetSignalPeak();
notifyPeakHolderChange();
}
/**
* Добавление массива значений сигнала
* @param values - массив значений
* @param bufferSize - размер буфера
*/
// noinspection JSUnusedGlobalSymbols
export function addSignals(values: number[], bufferSize: number): void {
if (!Array.isArray(values) || values.length === 0) return;
@@ -188,56 +217,7 @@ export function addSignals(values: number[], bufferSize: number): void {
}
}
/**
* Сброс отслеживания буфера
*/
// noinspection JSUnusedGlobalSymbols
export function resetBufferTracking(): void {
state.lastBufferSize = 0;
forceResetPeak();
}
// ============================================
// ВНУТРЕННИЕ ФУНКЦИИ
// ============================================
function notifyPeakHolderChange(): void {
if (peakHolderListener) {
peakHolderListener();
}
}
function scheduleAmplitudePeakRelease(): void {
// Сбрасываем старый таймер
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
// Запускаем новый таймер на 3 секунды
state.peakTimer = window.setTimeout(() => {
state.peakValue = state.currentValue;
state.peakTimer = null;
state.hasPeak = false;
console.log(`[Peak] Сброс пика до: ${state.peakValue}`);
notifyPeakHolderChange();
}, state.peakHoldTime);
}
function scheduleSignalPeakRelease(): void {
// Сбрасываем старый таймер
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
// Запускаем новый таймер на 3 секунды
state.signalPeakTimer = window.setTimeout(() => {
// Сбрасываем пик к текущему уровню
state.peakSignalLevel = state.currentSignalLevel;
state.signalPeakTimer = null;
state.hasPeak = false;
console.log(`[SignalPeak] Сброс пика до: ${state.peakSignalLevel}`);
notifyPeakHolderChange();
}, state.signalPeakHoldTime);
}

View File

@@ -1,4 +1,5 @@
// ui.ts (DOM слой)
// ui.ts - Управление UI элементами
export interface DOMElements {
serverTime: HTMLElement | null;
status: HTMLElement | null;
@@ -9,6 +10,10 @@ export interface DOMElements {
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;
@@ -24,6 +29,10 @@ export const DOM: DOMElements = {
canvas: null,
bars: document.querySelectorAll(".sig-bar"),
barFill: null,
bar2: null,
bar3: null,
soundLevel: null,
heightLevel: null,
historyHeader: null,
historyBody: null,
audioIndicator: null,
@@ -38,6 +47,10 @@ export function initDOM(): void {
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");
@@ -51,9 +64,9 @@ export function setServerTime(v: string): void {
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");
@@ -69,23 +82,29 @@ 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");
el.style.width = `${Math.max(0, Math.min(100, p))}%`;
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) {
@@ -94,9 +113,9 @@ export function setSignal(level: number, noConnection: boolean = false): void {
bar.classList.add("dim");
}
});
if (noConnection) return;
if (level >= 1) {
bars[0].classList.add("active-1");
bars[0].classList.remove("dim");
@@ -111,21 +130,286 @@ export function setSignal(level: number, noConnection: boolean = false): void {
}
}
// ФУНКЦИЮ ДЛЯ ИНДИКАТОРА ЗВУКА
export function setAudioIndicator(active: boolean, level: number = 0): void {
const el = DOM.audioIndicator;
if (!el) return;
if (active && level > 0) {
el.textContent = `🔊 ${level}`;
// Проверяем громкость
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);
});
});
}
});

View File

@@ -1,4 +1,3 @@
<!-- web/logs.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
@@ -38,7 +37,7 @@
<div class="logs-container">
<!-- Боковая панель с файлами -->
<div class="logs-sidebar">
<div class="logs-sidebar" id="logsSidebar">
<h3>Файлы данных</h3>
<div id="fileList"></div>
</div>
@@ -57,12 +56,13 @@
<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>
<!-- Пагинация будет добавлена динамически через JS -->
<!-- Пагинация для событий добавляется динамически -->
</div>
</div>
</div>

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

View File

@@ -1,3 +1,7 @@
// Package logger реализует хранение данных GPIO: разбор байта, бинарные
// почасовые логи с ротацией, человекочитаемые и событийные логи, watchdog
// тишины и retention (автоочистку старых файлов). Пути хранения зависят от
// режима работы (deb-пакет или разработка) и вычисляются в paths.go.
package logger
import "time"

View File

@@ -7,6 +7,8 @@ import (
"time"
)
// DataLogger пишет сэмплы в бинарный файл с буферизацией: буфер 64 КБ
// сбрасывается раз в секунду либо при заполнении, после сброса — fsync.
type DataLogger struct {
file *os.File
mu sync.Mutex
@@ -16,11 +18,14 @@ type DataLogger struct {
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)
@@ -40,6 +45,8 @@ func NewDataLogger(basePath string) (*DataLogger, error) {
return d, nil
}
// Write добавляет сэмпл в буфер в формате 9 байт:
// uint64 little-endian (микросекунды Unix) + 1 байт значения.
func (d *DataLogger) Write(s Sample) {
d.mu.Lock()
defer d.mu.Unlock()
@@ -91,6 +98,7 @@ func (d *DataLogger) flushLoop() {
}
}
// Close останавливает цикл сброса, дописывает остаток буфера и закрывает файл.
func (d *DataLogger) Close() error {
close(d.closeCh)
d.flushTick.Stop()

View File

@@ -8,12 +8,16 @@ import (
"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()
@@ -45,6 +49,7 @@ func NewEventLogger() (*EventLogger, error) {
}, nil
}
// Event записывает событие name в оба файла с текущим временем и sync.
func (l *EventLogger) Event(name string) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -74,6 +79,7 @@ func (l *EventLogger) Event(name string) {
l.humanFile.Sync()
}
// Close закрывает оба файла событий.
func (l *EventLogger) Close() error {
l.file.Close()
l.humanFile.Close()

View File

@@ -5,11 +5,14 @@ import (
"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 {
@@ -24,6 +27,7 @@ func NewHumanLogger() (*HumanLogger, error) {
return &HumanLogger{file: f}, nil
}
// Write дописывает готовую строку в лог.
func (l *HumanLogger) Write(data string) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -32,6 +36,7 @@ func (l *HumanLogger) Write(data string) {
l.file.Sync()
}
// Close закрывает файл лога.
func (l *HumanLogger) Close() error {
return l.file.Close()
}

View File

@@ -5,6 +5,9 @@ import (
"time"
)
// Monitor — watchdog потока данных: PipeReader отмечает каждую запись через
// RecordWrite, фоновый цикл сравнивает длительность тишины с порогами и пишет
// события ТИШИНА_5МИН/ТИШИНА_10МИН.
type Monitor struct {
eventLogger *EventLogger
lastWrite time.Time
@@ -14,6 +17,7 @@ type Monitor struct {
watchdogInterval time.Duration
}
// NewMonitor создаёт watchdog с порогами из config и сразу запускает фоновый цикл.
func NewMonitor(eventLogger *EventLogger, config *Config) *Monitor {
m := &Monitor{
eventLogger: eventLogger,
@@ -34,6 +38,7 @@ func (m *Monitor) SetSilenceThresholds(silence5Min, silence10Min time.Duration)
m.silence10Min = silence10Min
}
// RecordWrite отмечает факт приёма данных (сбрасывает отсчёт тишины).
func (m *Monitor) RecordWrite() {
m.mu.Lock()
m.lastWrite = time.Now()

View File

@@ -1,5 +1,7 @@
package logger
// GPIOData — разобранный байт GPIO-шины: количество обнаружений (биты 05),
// сила сигнала (биты 67) и признак превышения порога тревоги.
type GPIOData struct {
RawValue byte // сырое значение
Count byte // количество обнаружений (биты 0-5)

View File

@@ -7,7 +7,17 @@ import (
// GetDataDir возвращает стандартную директорию для данных приложения
func GetDataDir() (string, error) {
// Используем XDG Base Directory Specification
// Проверяем, установлен ли пакет (существует ли /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
@@ -15,8 +25,6 @@ func GetDataDir() (string, error) {
// ~/.local/share/gpio-monitoring
dataDir := filepath.Join(homeDir, ".local", "share", "gpio-monitoring")
// Создаём директорию если её нет
if err := os.MkdirAll(dataDir, 0755); err != nil {
return "", err
}
@@ -26,6 +34,16 @@ func GetDataDir() (string, error) {
// 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
@@ -82,4 +100,27 @@ func GetGPIOLogPath() (string, error) {
}
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

@@ -1,6 +1,7 @@
package logger
import (
"fmt"
"log"
"os"
"path/filepath"
@@ -9,6 +10,13 @@ import (
"time"
)
// minKeepFiles — сколько самых свежих файлов всегда сохраняем, даже если
// возраст или размер говорят удалить. Защита от полного стирания директории.
const minKeepFiles = 2
// Retention — фоновая FIFO-очистка бинарных логов по двум независимым лимитам:
// возрасту (maxAgeHours) и суммарному размеру (maxSizeBytes). Любой лимит
// отключается нулём; минимум minKeepFiles свежих файлов сохраняется всегда.
type Retention struct {
maxAgeHours int
maxSizeBytes int64
@@ -16,6 +24,7 @@ type Retention struct {
stopCh chan struct{}
}
// NewRetention создаёт очистку с лимитами; запуск — Start или StartWithInterval.
func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Retention {
return &Retention{
maxAgeHours: maxAgeHours,
@@ -25,11 +34,13 @@ func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Ret
}
}
// Start запускает очистку со стандартным интервалом 15 минут.
func (r *Retention) Start() {
// Запускаем проверку каждые 15 минут
r.StartWithInterval(15 * time.Minute)
}
// StartWithInterval запускает фоновый цикл очистки с заданным интервалом;
// первая очистка выполняется через 1 минуту после запуска.
func (r *Retention) StartWithInterval(interval time.Duration) {
// Запускаем проверку с указанным интервалом
ticker := time.NewTicker(interval)
@@ -51,10 +62,13 @@ func (r *Retention) StartWithInterval(interval time.Duration) {
})
}
// Stop останавливает фоновый цикл очистки.
func (r *Retention) Stop() {
close(r.stopCh)
}
// Cleanup выполняет один проход очистки: сначала по возрасту, затем по размеру.
// Удаления фиксируются суммарным событием ОЧИСТКАОГОВ_УДАЛЕНО_N_ФАЙЛОВ_M_MB.
func (r *Retention) Cleanup() {
// Если оба лимита отключены, ничего не делаем
if r.maxAgeHours <= 0 && r.maxSizeBytes <= 0 {
@@ -70,80 +84,53 @@ func (r *Retention) Cleanup() {
return
}
if r.eventLogger != nil {
r.eventLogger.Event("ЗАПУЩЕНА_ОЧИСТКАОГОВ")
}
// 1. Удаляем старые файлы (если включено)
deletedByAge := 0
var deletedByAgeBytes int64
if r.maxAgeHours > 0 {
deletedByAge = r.cleanByAge(dataDir)
deletedByAge, deletedByAgeBytes = r.cleanByAge(dataDir)
}
// 2. Проверяем общий размер и удаляем самые старые если превышен лимит (если включено)
deletedBySize := 0
var deletedBySizeBytes int64
if r.maxSizeBytes > 0 {
deletedBySize = r.cleanBySize(dataDir)
deletedBySize, deletedBySizeBytes = r.cleanBySize(dataDir)
}
if r.eventLogger != nil && (deletedByAge > 0 || deletedBySize > 0) {
r.eventLogger.Event("УДАЛЕНОАЙЛОВ")
log.Printf("Очистка логов: удалено %d файлов (по возрасту: %d, по размеру: %d)",
deletedByAge+deletedBySize, deletedByAge, deletedBySize)
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)
}
}
func (r *Retention) cleanByAge(dataDir string) int {
cutoff := time.Now().Add(-time.Duration(r.maxAgeHours) * time.Hour)
deleted := 0
// Получаем все .bin файлы
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
if err != nil {
return 0
}
for _, file := range files {
info, err := os.Stat(file)
if err != nil {
continue
}
// Извлекаем время из имени файла
fileTime := r.parseFilenameTime(file)
if fileTime.IsZero() {
fileTime = info.ModTime()
}
if fileTime.Before(cutoff) {
if err := os.Remove(file); err == nil {
deleted++
if r.eventLogger != nil {
r.eventLogger.Event("УДАЛЕНАЙЛ_ПО_ВОЗРАСТУ")
}
}
}
}
return deleted
// fileInfo описывает один лог-файл: путь, время из имени (или ModTime как
// запасной вариант), реальное время последней записи и размер.
type fileInfo struct {
path string
time time.Time
modTime time.Time
size int64
}
func (r *Retention) cleanBySize(dataDir string) int {
// Получаем все файлы
// 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 0
}
if len(files) == 0 {
return 0
}
// Сортируем по времени (старыми первыми)
type fileInfo struct {
path string
time time.Time
size int64
return nil, 0
}
var fileList []fileInfo
@@ -155,42 +142,85 @@ func (r *Retention) cleanBySize(dataDir string) int {
continue
}
// Время из имени файла; если распарсить не удалось — берём ModTime.
fileTime := r.parseFilenameTime(file)
if fileTime.IsZero() {
fileTime = info.ModTime()
}
fileList = append(fileList, fileInfo{
path: file,
time: fileTime,
size: info.Size(),
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
for totalSize > r.maxSizeBytes && len(fileList) > 1 {
// Удаляем самый старый файл
oldest := fileList[0]
if err := os.Remove(oldest.path); err == nil {
totalSize -= oldest.size
deleted++
fileList = fileList[1:]
if r.eventLogger != nil {
r.eventLogger.Event("УДАЛЕНАЙЛ_ПО_РАЗМЕРУ")
}
} else {
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
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 {
@@ -201,10 +231,22 @@ func (r *Retention) parseFilenameTime(filename string) time.Time {
return time.Time{}
}
year, _ := time.Parse("2006", parts[1])
month, _ := time.Parse("01", parts[2])
day, _ := time.Parse("02", parts[3])
hour, _ := time.Parse("15", parts[4])
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)
}
}
}

View File

@@ -7,6 +7,9 @@ import (
"time"
)
// RotatingLogger — обёртка над DataLogger с почасовой ротацией: держит файл
// текущего часа (gpio-YYYY-MM-DD-HH.bin) и по таймеру переоткрывает его при
// смене часа, фиксируя событие ПЛАНОВАЯ_РОТАЦИЯ.
type RotatingLogger struct {
dataLogger *DataLogger
currentHour int
@@ -16,6 +19,8 @@ type RotatingLogger struct {
checkInterval time.Duration
}
// NewRotatingLogger открывает файл текущего часа в каталоге бинарных данных
// и запускает цикл проверки ротации (checkInterval; 0 — раз в минуту).
func NewRotatingLogger(eventLogger *EventLogger, checkInterval time.Duration) (*RotatingLogger, error) {
// Получаем директорию для бинарных данных
dataDir, err := GetDataLogsDir()
@@ -70,7 +75,7 @@ func (r *RotatingLogger) rotate() error {
dataLogger, err := NewDataLogger(filename)
if err != nil {
if r.eventLogger != nil {
r.eventLogger.Event("ROTATION_FAILED")
r.eventLogger.Event("ПЛАНОВАЯ_РОТАЦИЯ_НЕУДАЧНА")
}
return err
}
@@ -78,11 +83,12 @@ func (r *RotatingLogger) rotate() error {
r.dataLogger = dataLogger
r.currentHour = newHour
if r.eventLogger != nil {
r.eventLogger.Event("ROTATION_COMPLETE")
r.eventLogger.Event("ПЛАНОВАЯ_РОТАЦИЯ")
}
return nil
}
// Write передаёт сэмпл текущему DataLogger.
func (r *RotatingLogger) Write(s Sample) {
r.mu.Lock()
logger := r.dataLogger
@@ -102,6 +108,7 @@ func (r *RotatingLogger) rotationLoop() {
}
}
// Close закрывает текущий DataLogger (с финальным сбросом буфера).
func (r *RotatingLogger) Close() error {
r.mu.Lock()
defer r.mu.Unlock()

View File

@@ -1,3 +1,5 @@
// Package pipe читает поток байт GPIO из именованного канала (FIFO) и раздаёт
// их потребителям: кольцевому буферу, бинарному и текстовым логгерам, watchdog.
package pipe
import (
@@ -10,6 +12,10 @@ import (
"gpio-monitor/internal/logger"
)
// PipeReader — единственный «производитель» данных в системе: следит за FIFO,
// переподключается при обрывах и раздаёт каждый принятый байт потребителям.
// Ведёт машину состояний пайпа (unknown/found/not_found/error/disconnected)
// и пишет события смены состояния без дублей.
type PipeReader struct {
buf *adapter.RingBuffer
dataLogger *logger.RotatingLogger
@@ -23,10 +29,16 @@ type PipeReader struct {
lastAlertTime map[byte]time.Time
// Настройки
alertCooldownSec time.Duration
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,
@@ -36,17 +48,32 @@ func NewPipeReader(
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,
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)
@@ -59,8 +86,10 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
}
if _, err := os.Stat(pipePath); os.IsNotExist(err) {
if pr.eventLog != nil {
pr.eventLog.Event("PIPE_НЕ_НАЙДЕН")
// Логируем ТОЛЬКО при смене состояния
if pr.pipeState != "not_found" {
pr.pipeState = "not_found"
pr.logStateChange("not_found", "PIPE_НЕ_НАЙДЕН")
}
time.Sleep(2 * time.Second)
continue
@@ -68,27 +97,38 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
f, err := os.OpenFile(pipePath, os.O_RDONLY, 0)
if err != nil {
if pr.eventLog != nil {
pr.eventLog.Event("ОШИБКА_ОТКРЫТИЯ_PIPE")
if pr.pipeState != "error" {
pr.pipeState = "error"
pr.logStateChange("error", "ОШИБКА_ОТКРЫТИЯ_PIPE")
}
time.Sleep(time.Second)
continue
}
if pr.eventLog != nil {
pr.eventLog.Event("PIPE_ПОДКЛЮЧЕН")
// 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.eventLog != nil {
pr.eventLog.Event("PIPE_ОТКЛЮЧЕН")
// Отключаемся только если были подключены
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()
@@ -155,6 +195,8 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
}()
}
// handleAlert пишет алерт превышения (count > 10) в event- и human-логи,
// не чаще одного раза в alertCooldownSec для одинакового значения count.
func (pr *PipeReader) handleAlert(data logger.GPIOData, timestamp time.Time) {
// Anti-spam: не чаще 1 алерта в N секунд для одинакового количества
key := data.Count

View File

@@ -1,8 +1,11 @@
"""
# Создание сокета mkfifo /tmp/gpio_pipe
# Необходим файл с данными, например array.txt. Формат записи данных [0xE0, 0x01, 0x4C, 0x88]
# Запуск python3 emulator.py array.txt /tmp/gpio_pipe
Эмулятор чтения из файла и отправки в pipe.
Создание сокета: mkfifo /tmp/gpio_pipe
Формат файла: [0xE0, 0x01, 0x4C, 0x88]
Запуск: python3 emulator.py array.txt /tmp/gpio_pipe
"""
import sys
import time
import ast
@@ -49,6 +52,7 @@ def status_clear():
# ================= DATA =================
def load_array(path):
"""Загружает данные из файла."""
while True:
try:
with open(path, "r") as f:
@@ -73,6 +77,7 @@ def load_array(path):
# ================= PIPE =================
def open_pipe(path):
"""Открывает pipe для записи."""
while not stop_flag:
try:
fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK)

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