Доработка документации

This commit is contained in:
Maxim
2026-07-17 15:57:05 +03:00
parent 5b403d7ee4
commit 9fa3d172a8
22 changed files with 1371 additions and 481 deletions

541
README.md
View File

@@ -1,508 +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 alsa-utils
git clone <your-repo> && cd golang
make init # подготовка окружения (Node.js, Go-модули, npm-пакеты)
make build # TypeScript-фронтенд + Go-сервер → build/gpio-monitor-server
```
> `alsa-utils` нужен для монитора уровня звука (использует `arecord`). Проверить, что микрофон виден системе:
> ```bash
> arecord -l
> # **** List of CAPTURE Hardware Devices ****
> # card 3: Device [USB PnP Sound Device], device 0: USB Audio [USB Audio]
> ```
> Если список пуст — микрофон не подключён или не определился, монитор звука будет отдавать `sound_level: 0`.
Подробности, кросс-компиляция, эмуляция без железа — [docs/development.md](docs/development.md).
### 2. Клонирование и сборка
```bash
git clone <your-repo>
cd ~/work/golang
# Полная подготовка окружения (проверка Node.js, скачивание Go-модулей и npm-пакетов)
make init
```
### 3. Сборка проекта
``` Bash
# Скомпилируйте TypeScript-фронтенд и Go-сервер одной командой:
make build
```
### 4. Настройка systemd сервиса
Для постоянной работы сервера в фоновом режиме настройте систему инициализации. Поскольку сервер пишет логи в защищенную директорию /var/log/, его запуск и управление осуществляются через systemctl с правами администратора
```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).
---
## Ротация и хранение логов (Retention)
Бинарные данные GPIO пишутся в почасовые файлы вида `gpio-YYYY-MM-DD-HH.bin`
в директории `internal/logger` (`GetDataLogsDir()`). За это отвечают два
независимых механизма:
- **Ротация** (`internal/logger/rotation.go`) — каждую минуту (`-rotation-check-interval`)
проверяет текущий час и, если он изменился, закрывает старый `.bin`-файл и
открывает новый. Ротация только создаёт новые файлы, старые она не трогает.
- **Retention** (`internal/logger/retention.go`) — раз в `-retention-interval`
минут (плюс один прогон через минуту после старта) удаляет лишние файлы по
двум независимым лимитам: возрасту (`-retention-hours`) и суммарному размеру
(`-retention-mb`). Любой из лимитов можно отключить, выставив `0`.
### Как работает удаление (FIFO)
Очистка идёт по правилам:
1. Все `gpio-*.bin` сортируются от самого старого к самому новому (FIFO-порядок).
2. Файлы удаляются строго с "старого" конца списка, по одному, пока выполняются
оба условия:
- файл старше порога **и по времени в имени файла, и по реальному времени
последней записи (ModTime)** — если файл был записан недавно, но у него
"старое" имя (или наоборот), он не считается кандидатом на удаление;
- файлов в директории больше `minKeepFiles` (сейчас — 2).
3. Как только встречается первый файл, который не удовлетворяет условиям,
очистка останавливается — все файлы правее (более новые) заведомо тоже
не подходят под удаление.
Итог: Очистка по размеру (`cleanBySize`): удаляет самые старые файлы,
пока суммарный объём превышает `-retention-mb`, но тоже не опускается ниже
`minKeepFiles`.
Дополнительная защита — при разборе имени файла (`parseFilenameTime`) любая
ошибка парсинга (битое или неожиданное имя) не считается "нулевым/древним"
временем, а приводит к откату на реальный `ModTime` файла с диска, чтобы
повреждённое или нестандартное имя не привело к ошибочному удалению.
---
## 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, статистика, sound_level (0-100, уровень с микрофона)
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
```
### Если индикатор "🔊 ЗВУК" на dashboard не реагирует на микрофон:
1. **Проверьте, что arecord установлен и видит микрофон:**
```bash
which arecord
arecord -l
```
2. **Проверьте лог сервера при запуске** — если микрофон не найден, будет строка:
```
Аудио-монитор не запущен (микрофон недоступен?): ...
```
3. **Проверьте права доступа к аудио-устройству** (сервис должен работать от пользователя из группы `audio`):
```bash
groups <user>
sudo usermod -aG audio <user>
```
Форматы файлов, словарь событий и алгоритм очистки — [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`
---
## Сборка и управление DEB-пакетом
В проект добавлена возможность сборки нативного `.deb` пакета для Raspberry Pi OS. Пакет автоматически упаковывает скомпилированный Go-сервер, собранный TypeScript-фронтенд, скрипты логов, конфигурационные файлы и systemd-сервисы.
Сборка пакета выполняется одной командой. **Запуск от обычного пользователя (без sudo):**
```bash
make deb
```
После завершения файл пакета будет доступен по пути: ./build/gpio-monitor-server.deb
Установка собранного пакета
После того как пакет собран, его можно установить в систему с помощью менеджера пакетов apt (он автоматически подтянет системные зависимости, если они требуются):
```Bash
cd ./build
sudo dpkg -i gpio-monitor-server_1.0.0-1_arm64.deb
```
Полезные команды для работы с пакетом
В Makefile предусмотрены дополнительные команды для проверки и очистки пакета перед деплоем:
Просмотр информации о пакете (версия, архитектура, зависимости, описание):
```Bash
# Просмотр содержимого пакета, список файлов и папок:
make deb-info
```
```Bash
# Очистка временных файлов сборки пакета:
make deb-contents
```
```Bash
make deb-clean
sudo apt purge gpio-monitor-server
```
---
Полный чек-лист (дашборд, звук, камера, «Нет связи») — [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 (
@@ -28,6 +32,8 @@ import (
//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
@@ -39,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 {
@@ -105,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
@@ -220,7 +228,9 @@ 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 == "" {
@@ -400,6 +410,8 @@ func (a *API) HandleLogData(w http.ResponseWriter, r *http.Request) {
})
}
// 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())
@@ -429,6 +441,7 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
})
}
// HandleLatest отдаёт последний байт и до 10 предыдущих (от старых к новым).
func (a *API) HandleLatest(w http.ResponseWriter, r *http.Request) {
latest, ok := a.buf.GetLatest()
if !ok {
@@ -442,6 +455,7 @@ 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(300)
@@ -455,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")
@@ -470,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)
@@ -519,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", "*")
@@ -703,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).

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

View File

@@ -1,3 +1,7 @@
// Package audio измеряет уровень звука с микрофона через arecord (ALSA):
// USB-устройство находится автоматически, PCM-поток читается блоками по 100 мс,
// уровень считается как RMS и публикуется по шкале 0100 (поле sound_level
// в /api/health). Падение arecord не фатально — захват перезапускается.
package audio
import (
@@ -15,6 +19,7 @@ import (
"time"
)
// Monitor — фоновый измеритель уровня звука с микрофона.
type Monitor struct {
mu sync.RWMutex
currentLevel int
@@ -22,16 +27,21 @@ type Monitor struct {
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
@@ -156,6 +166,8 @@ func (m *Monitor) handleCrash(ctx context.Context) {
}
}
// findUSBDevice выбирает ALSA-устройство по выводу arecord -l: сначала USB-аудио,
// затем любое устройство захвата, иначе "default".
func (m *Monitor) findUSBDevice() string {
cmd := exec.Command("arecord", "-l")
output, err := cmd.Output()
@@ -201,6 +213,7 @@ func (m *Monitor) findUSBDevice() string {
return "default"
}
// Stop останавливает захват и дожидается завершения горутины чтения.
func (m *Monitor) Stop() {
if m.cancel != nil {
m.cancel()

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

@@ -14,6 +14,9 @@ import (
// возраст или размер говорят удалить. Защита от полного стирания директории.
const minKeepFiles = 2
// Retention — фоновая FIFO-очистка бинарных логов по двум независимым лимитам:
// возрасту (maxAgeHours) и суммарному размеру (maxSizeBytes). Любой лимит
// отключается нулём; минимум minKeepFiles свежих файлов сохраняется всегда.
type Retention struct {
maxAgeHours int
maxSizeBytes int64
@@ -21,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,
@@ -30,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)
@@ -56,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 {

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()
@@ -83,6 +88,7 @@ func (r *RotatingLogger) rotate() error {
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
@@ -31,6 +37,8 @@ type PipeReader struct {
lastLoggedState string // для отслеживания изменений
}
// NewPipeReader создаёт читателя с настройками анти-спама из config.
// Любой из потребителей (dataLogger, humanLogger, monitor, eventLog) может быть nil.
func NewPipeReader(
buf *adapter.RingBuffer,
dataLogger *logger.RotatingLogger,
@@ -63,6 +71,9 @@ func (pr *PipeReader) logStateChange(newState string, eventName string) {
}
}
// Start запускает чтение pipePath в отдельной горутине и сразу возвращается.
// Если FIFO отсутствует — ждёт его появления (проверка каждые 2 с); при обрыве
// чтения переоткрывает файл через 1 с. Останавливается по отмене ctx.
func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
go func() {
buffer := make([]byte, 4096)
@@ -184,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