Compare commits

...

7 Commits

18 changed files with 1849 additions and 214 deletions

372
README.md
View File

@@ -2,56 +2,125 @@
Реалтайм мониторинг параллельной GPIO-шины Raspberry Pi через C + Go + Web Dashboard.
Проект читает входящие сигналы с GPIO-пинов Raspberry Pi по прерыванию, передаёт данные через FIFO pipe в Go-сервер и отображает всё в браузере в реальном времени.
Проект читает входящие сигналы с GPIO-пинов Raspberry Pi по прерыванию, передаёт данные через FIFO pipe в Go-сервер и отображает всё в браузере в реальном времени с возможностью просмотра исторических данных.
---
# Возможности
## Возможности
✅ Захват данных по GPIO interrupt
Работа через WiringPi
FIFO pipe между C и Go
Высокоскоростной RingBuffer
HTTP API
Web Dashboard
Работа через Wi-Fi Access Point Raspberry Pi
Подходит для ПК и мобильных устройств
✅ Захват данных по GPIO interrupt (C + WiringPi)
FIFO pipe, а также stdin/stdout между C + WiringPi и Go Service
Реализация кольцевого буфера чтения
HTTP API + Web Dashboard
**Автоматическая ротация** лог-файлов
**Retention** (автоматическая очистка старых логов)
**Watchdog** мониторинг активности
**MJPEG камера** через go2rtc
✅ TypeScript фронтенд
✅ Работа через Wi-Fi Access Point
---
# Архитектура
## Архитектура
```text
GPIO BUS (8 bit)
gpio-interrupt.c
gpio-interrupt.c (C + WiringPi)
↓ stdout
systemd socket FIFO (/tmp/gpio_pipe)
FIFO pipe (/tmp/gpio_pipe)
Go Server
Go Server (gpio-monitor-server)
├── RingBuffer (RAM)
├── Data Logger (бинарные .bin файлы)
├── Human Logger (текстовые логи)
├── Event Logger (события системы)
├── Watchdog (мониторинг)
└── Retention (очистка старых логов)
HTTP API (:8080)
├── /api/health - статус сервера
├── /api/latest - последние данные
├── /api/history - история (последние 100)
├── /api/stream - статус камеры
├── /api/cam - MJPEG поток
├── /api/log/files - список лог-файлов
├── /api/log/data - данные из лог-файла
└── /api/log/events - события системы
Dashboard (Browser)
Web Dashboard (Embedded)
├── index.html
├── dashboard.html (главная)
├── logs.html (просмотр логов)
├── css/style.css
├── js/*.ts (TypeScript исходники)
└── dist/*.js (скомпилированный JS)
```
# Структура проекта
---
## Структура проекта
```
.
├── cmd/server/main.go # Go HTTP сервер
├── internal/adapter/ # RingBuffer
├── web/
│ ├── index.html
│ ├── dashboard.html
── css/style.css
├── js/app.js
└── favicon.png
├── build/
│ └── gpio-monitor-server # Скомпилированный бинарник
├── build.sh # Скрипт сборки (Go + TypeScript)
├── cmd/server/
│ ├── 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
│ └── README.md
├── internal/
│ ├── adapter/buffer.go # RingBuffer
│ ├── logger/ # Система логирования
│ │ ├── config.go
│ │ ├── data_logger.go # Бинарные логи
│ │ ├── event_logger.go # События
│ │ ├── human_logger.go # Человекочитаемые логи
│ │ ├── monitor.go # Watchdog
│ │ ├── parser.go # Парсер данных GPIO
│ │ ├── paths.go # Пути к файлам
│ │ ├── retention.go # Очистка старых логов
│ │ └── 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
└── README.md
```
# GPIO pin
## GPIO пины
```
WR/STROBE = GPIO27
DATA BUS:
@@ -63,39 +132,262 @@ D4 = GPIO25
D5 = GPIO24
D6 = GPIO23
D7 = GPIO22
```
# Установка
---
## Установка
### 1. Установка зависимостей
```bash
sudo apt update
sudo apt install golang git build-essential
sudo apt install -y golang git build-essential wiringpi nodejs npm
```
# Сборка
### 2. Клонирование и сборка
go build -o gpio-monitor-server ./cmd/server
```bash
git clone <your-repo>
cd ~/work/golang
# Запуск сервера
# Сборка TypeScript + Go
./build.sh
```
./start-server.sh
### 3. Настройка systemd сервиса
```bash
# Копировать сервисные файлы
sudo cp scripts/monitor-gpio.service /etc/systemd/system/
sudo cp scripts/monitor-gpio.socket /etc/systemd/system/
# Перезагрузить systemd
sudo systemctl daemon-reload
# Включить автозапуск
sudo systemctl enable monitor-gpio.socket
sudo systemctl enable monitor-gpio.service
# Запустить сервис
sudo systemctl start monitor-gpio.service
```
# Dashboard
### 4. Проверка статуса
Открывать на http://192.168.77.1:8080
```bash
sudo systemctl status monitor-gpio.service
journalctl -u monitor-gpio.service -f
```
# API
---
## Настройка 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
```
### Параметры командной строки
| Параметр | По умолчанию | Описание |
|----------|-------------|----------|
| `-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 камеры |
---
## 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
### Основные эндпоинты
```bash
GET /api/health
# Статус сервера, uptime, статистика
GET /api/latest
# Последние данные и история (10 записей)
GET /api/history
# Последние 100 записей
# Wi-Fi режим Raspberry Pi
GET /api/stream
# Статус камеры
SSID: fix_me
IP: 192.168.77.1
GET /api/cam
# MJPEG поток камеры (прокси на go2rtc)
```
Подключение: ssh user@192.168.77.1
### Работа с логами
# License
```bash
GET /api/log/files
# Список доступных лог-файлов
MIT
GET /api/log/data?file=gpio-2026-06-17-11.bin
# Данные из конкретного бинарного лога
GET /api/log/events?limit=100
# Последние события системы
```
---
## Диагностика проблем
### Если Dashboard не открывается:
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
```
---
## 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
```
### Эмуляция 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`
---

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"embed"
"encoding/binary"
"encoding/json"
"flag"
"fmt"
@@ -10,6 +11,10 @@ import (
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -33,6 +38,215 @@ func writeJSON(w http.ResponseWriter, v any) {
json.NewEncoder(w).Encode(v)
}
// HandleLogFiles - список доступных лог-файлов
func (a *API) HandleLogFiles(w http.ResponseWriter, r *http.Request) {
dataDir, err := logger.GetDataLogsDir()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
type LogFileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
Time string `json:"time"`
Date string `json:"date"`
Hour int `json:"hour"`
IsActive bool `json:"is_active"`
}
var result []LogFileInfo
for _, file := range files {
info, err := os.Stat(file)
if err != nil {
continue
}
// Парсим имя файла: gpio-2026-06-16-11.bin
base := filepath.Base(file)
parts := strings.Split(strings.TrimSuffix(base, ".bin"), "-")
var fileTime time.Time
if len(parts) == 5 {
year, _ := strconv.Atoi(parts[1])
month, _ := strconv.Atoi(parts[2])
day, _ := strconv.Atoi(parts[3])
hour, _ := strconv.Atoi(parts[4])
fileTime = time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.Local)
} else {
fileTime = info.ModTime()
}
result = append(result, LogFileInfo{
Name: base,
Path: file,
Size: info.Size(),
ModTime: info.ModTime(),
Time: fileTime.Format("02.01.2006 15:00"),
Date: fileTime.Format("2006-01-02"),
Hour: fileTime.Hour(),
IsActive: false,
})
}
// Сортируем по времени (новые сверху)
sort.Slice(result, func(i, j int) bool {
return result[i].ModTime.After(result[j].ModTime)
})
writeJSON(w, result)
}
// HandleLogData - чтение данных из конкретного bin файла
func (a *API) HandleLogData(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
if filename == "" {
http.Error(w, "missing file parameter", 400)
return
}
// Защита от path traversal
filename = filepath.Base(filename)
dataDir, err := logger.GetDataLogsDir()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
filePath := filepath.Join(dataDir, filename)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.Error(w, "file not found", 404)
return
}
// Читаем бинарный файл
f, err := os.Open(filePath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer f.Close()
type Sample struct {
Timestamp int64 `json:"ts"`
Time string `json:"time"`
Value byte `json:"value"`
Count byte `json:"count"`
Strength byte `json:"strength"`
StrengthName string `json:"strength_name"`
}
var samples []Sample
buf := make([]byte, 9) // 8 байт timestamp + 1 байт value
for {
n, err := f.Read(buf)
if err == io.EOF {
break
}
if err != nil {
http.Error(w, err.Error(), 500)
return
}
if n < 9 {
continue
}
ts := int64(binary.LittleEndian.Uint64(buf[0:8]))
value := buf[8]
data := logger.ParseGPIO(value)
samples = append(samples, Sample{
Timestamp: ts,
Time: time.UnixMicro(ts).Format("2006-01-02 15:04:05.000"),
Value: value,
Count: data.Count,
Strength: data.Strength,
StrengthName: logger.GetStrengthName(data.Strength),
})
}
// Ограничиваем количество точек для производительности
if len(samples) > 10000 {
// Берем каждую N-ю точку
step := len(samples) / 10000
var filtered []Sample
for i := 0; i < len(samples); i += step {
filtered = append(filtered, samples[i])
}
samples = filtered
}
writeJSON(w, map[string]any{
"filename": filename,
"total": len(samples),
"samples": samples,
})
}
// HandleLogEvents - чтение событий
func (a *API) HandleLogEvents(w http.ResponseWriter, r *http.Request) {
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil {
limit = parsed
}
}
eventsPath, err := logger.GetEventHumanLogPath()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
content, err := os.ReadFile(eventsPath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
lines := strings.Split(string(content), "\n")
// Берем последние N строк
if len(lines) > limit+1 {
lines = lines[len(lines)-limit-1:]
}
// Парсим строки для JSON
type Event struct {
Time string `json:"time"`
Event string `json:"event"`
}
var events []Event
for _, line := range lines {
if line == "" {
continue
}
// Формат: [2026-06-16 11:23:45.123] EVENT: СЕРВЕР_ЗАПУЩЕН
parts := strings.SplitN(line, "] EVENT: ", 2)
if len(parts) == 2 {
events = append(events, Event{
Time: strings.TrimPrefix(parts[0], "["),
Event: parts[1],
})
}
}
writeJSON(w, map[string]any{
"events": events,
"total": len(events),
})
}
func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
latest, ok := a.buf.GetLatest()
idle := time.Since(a.buf.LastWriteTime())
@@ -42,6 +256,8 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
status = "Нет связи"
}
now := time.Now()
writeJSON(w, map[string]any{
"status": status,
"uptime_sec": time.Since(a.startTime).Seconds(),
@@ -50,6 +266,7 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
"has_data": ok,
"latest": latest,
"stats": a.buf.Stats(),
"server_time": now.Format("02.01.2006 15:04"),
})
}
@@ -159,9 +376,69 @@ func cors(next http.HandlerFunc) http.HandlerFunc {
}
func main() {
// Параметры командной строки
pipePath := flag.String("pipe", "/tmp/gpio_pipe", "путь к pipe")
// Параметры retention (хранения)
retentionHours := flag.Int("retention-hours", 48, "количество часов хранения логов (0 = отключено)")
retentionMB := flag.Int("retention-mb", 5000, "максимальный размер логов в MB (0 = отключено)")
retentionIntervalMin := flag.Int("retention-interval", 15, "интервал проверки retention в минутах")
// Параметры ротации файлов
rotationCheckIntervalMin := flag.Int("rotation-check-interval", 1, "интервал проверки ротации в минутах")
// Параметры буфера
bufferSize := flag.Int("buffer-size", 10240, "размер кольцевого буфера (количество элементов)")
// Параметры мониторинга
silence5minAlert := flag.Int("silence-5min", 5, "время тишины для алерта 5 минут (в минутах)")
silence10minAlert := flag.Int("silence-10min", 10, "время тишины для алерта 10 минут (в минутах)")
watchdogIntervalMin := flag.Int("watchdog-interval", 1, "интервал проверки watchdog в минутах")
// Параметры анти-спама
alertCooldownSec := flag.Int("alert-cooldown", 10, "задержка между одинаковыми алертами в секундах")
humanLogIntervalSec := flag.Int("human-log-interval", 5, "интервал записи human-readable логов в секундах")
// Параметры сервера
serverPort := flag.String("port", ":8080", "порт для веб-сервера")
cameraURL := flag.String("camera-url", "http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg", "URL камеры для прокси")
flag.Parse()
// Конвертируем в time.Duration
retentionInterval := time.Duration(*retentionIntervalMin) * time.Minute
rotationCheckInterval := time.Duration(*rotationCheckIntervalMin) * time.Minute
silence5min := time.Duration(*silence5minAlert) * time.Minute
silence10min := time.Duration(*silence10minAlert) * time.Minute
watchdogInterval := time.Duration(*watchdogIntervalMin) * time.Minute
alertCooldown := time.Duration(*alertCooldownSec) * time.Second
humanLogInterval := time.Duration(*humanLogIntervalSec) * time.Second
// Сохраняем параметры для передачи в логгеры
config := &logger.Config{
RetentionHours: *retentionHours,
RetentionMB: *retentionMB,
RetentionIntervalMin: retentionInterval,
Silence5Min: silence5min,
Silence10Min: silence10min,
WatchdogIntervalMin: watchdogInterval,
AlertCooldownSec: alertCooldown,
HumanLogIntervalSec: humanLogInterval,
BufferSize: *bufferSize,
}
// Логируем конфигурацию
log.Printf("=== КОНФИГУРАЦИЯ ===")
log.Printf("Retention: %d часов, %d MB (проверка каждые %d мин)",
*retentionHours, *retentionMB, *retentionIntervalMin)
log.Printf("Ротация: проверка каждые %d мин", *rotationCheckIntervalMin)
log.Printf("Буфер: %d элементов", *bufferSize)
log.Printf("Алерты тишины: %d мин и %d мин (проверка каждые %d мин)",
*silence5minAlert, *silence10minAlert, *watchdogIntervalMin)
log.Printf("Анти-спам: %d сек, Human-лог: %d сек", *alertCooldownSec, *humanLogIntervalSec)
log.Printf("Сервер: %s, Камера: %s", *serverPort, *cameraURL)
log.Printf("====================")
// ===== ИНИЦИАЛИЗАЦИЯ ЛОГГЕРОВ =====
// 1. EventLogger (события системы)
@@ -185,11 +462,12 @@ func main() {
defer humanLogger.Close()
eventLogger.Event("HUMAN_ЛОГЕРОТОВ")
// 2. Monitor (watchdog)
monitor := logger.NewMonitor(eventLogger)
// 2. Monitor (watchdog) с настройками
monitor := logger.NewMonitor(eventLogger, config)
monitor.SetSilenceThresholds(config.Silence5Min, config.Silence10Min)
// 3. DataLogger с ротацией
dataLogger, err := logger.NewRotatingLogger(eventLogger)
// 3. DataLogger с ротацией (с интервалом проверки)
dataLogger, err := logger.NewRotatingLogger(eventLogger, rotationCheckInterval)
if err != nil {
eventLogger.Event("ОШИБКАНИЦИАЛИЗАЦИИ_DATA_ЛОГЕРА")
log.Fatal("Ошибка инициализации DataLogger:", err)
@@ -197,16 +475,20 @@ func main() {
defer dataLogger.Close()
eventLogger.Event("DATA_ЛОГЕРОТОВ")
// 3.5 Retention (автоматическая очистка)
retention := logger.NewRetention(48, 5000, eventLogger)
retention.Start()
eventLogger.Event("RETENTION_ГОТОВ")
// 3.5 Retention (автоматическая очистка) с настройками
if *retentionHours > 0 || *retentionMB > 0 {
retention := logger.NewRetention(*retentionHours, *retentionMB, eventLogger)
retention.StartWithInterval(retentionInterval)
eventLogger.Event("RETENTION_ГОТОВ")
} else {
log.Println("Retention отключен (часы и MB = 0)")
}
// 4. RingBuffer
buf := adapter.NewRingBuffer(1024 * 10)
// 4. RingBuffer с настройкой размера
buf := adapter.NewRingBuffer(*bufferSize)
// 5. PipeReader
pipeReader := pipe.NewPipeReader(buf, dataLogger, humanLogger, monitor, eventLogger)
// 5. PipeReader с настройками
pipeReader := pipe.NewPipeReader(buf, dataLogger, humanLogger, monitor, eventLogger, config)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pipeReader.Start(ctx, *pipePath)
@@ -229,7 +511,14 @@ func main() {
case "/stream":
cors(api.HandleStream)(w, r)
case "/cam":
handleCamProxy(w, r)
// Передаем URL камеры из конфигурации
handleCamProxyWithURL(w, r, *cameraURL)
case "/log/files":
cors(api.HandleLogFiles)(w, r)
case "/log/data":
cors(api.HandleLogData)(w, r)
case "/log/events":
cors(api.HandleLogEvents)(w, r)
default:
http.Error(w, "not found", 404)
}
@@ -242,7 +531,56 @@ func main() {
}
http.Handle("/", http.FileServer(http.FS(webFS)))
log.Println("Server started on :8080")
log.Println("Camera proxy available at /api/cam")
log.Fatal(http.ListenAndServe(":8080", nil))
log.Printf("Server started on %s", *serverPort)
log.Printf("Camera proxy available at /api/cam (source: %s)", *cameraURL)
log.Fatal(http.ListenAndServe(*serverPort, nil))
}
// handleCamProxyWithURL проксирует MJPEG поток с указанным URL
func handleCamProxyWithURL(w http.ResponseWriter, r *http.Request, cameraURL string) {
log.Printf("[cam] proxy request from %s to %s", r.RemoteAddr, cameraURL)
resp, err := http.Get(cameraURL)
if err != nil {
log.Printf("[cam] camera unavailable: %v", err)
http.Error(w, "camera unavailable", 503)
return
}
defer resp.Body.Close()
// Прокидываем заголовки
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(resp.StatusCode)
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", 500)
return
}
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, err = w.Write(buf[:n])
if err != nil {
log.Printf("[cam] client disconnected")
return
}
flusher.Flush()
}
if err != nil {
if err != io.EOF {
log.Printf("[cam] stream ended: %v", err)
}
return
}
}
}

View File

@@ -31,3 +31,7 @@
#### Установка TypeScript и типов Node.js
```npm install -D typescript @types/node```
```bash
chmod +x node_modules/.bin/tsc
```

View File

@@ -12,9 +12,9 @@ body {
/* ===== HEADER BAR ===== */
.header-bar {
display: flex;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
gap: 16px;
}
@@ -22,7 +22,20 @@ body {
h1 {
font-size: 22px;
margin: 0;
flex: 1;
}
.header-time {
font-size: 16px;
text-align: center;
white-space: nowrap;
padding: 6px 12px;
border: 1px solid rgba(0, 255, 136, 0.35);
border-radius: 8px;
background: rgba(0, 255, 136, 0.05);
}
.header-bar .cam-btn {
justify-self: end;
}
/* ===== CAMERA BUTTON (теперь в header) ===== */
@@ -406,6 +419,53 @@ h1 {
inset 0 0 10px rgba(0, 255, 136, 0.1);
}
/* ===== AUDIO INDICATOR ===== */
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(0.95);
}
}
#audio-indicator {
transition: all 0.3s ease;
font-family: monospace;
font-size: 16px;
padding: 4px 12px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #333;
min-width: 50px;
text-align: center;
user-select: none;
}
#audio-indicator.active {
border-color: #00ff88;
box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
animation: pulse 1s ease-in-out infinite;
}
/* ===== ГОРЯЧИЕ КЛАВИШИ (подсказка) ===== */
.hotkey-hint {
font-size: 11px;
opacity: 0.4;
text-align: center;
padding: 4px 0;
}
.hotkey-hint kbd {
background: rgba(0, 255, 136, 0.1);
padding: 1px 6px;
border-radius: 3px;
border: 1px solid rgba(0, 255, 136, 0.2);
font-size: 10px;
}
/* ===== MOBILE ===== */
@media (max-width: 500px) {
.header-bar {

View File

@@ -13,10 +13,12 @@
<div class="header-bar">
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
<button id="camToggle" class="cam-btn">
<span class="cam-btn-icon">📷</span>
<span class="cam-btn-text">Камера</span>
</button>
<div style="display:flex; gap:12px; align-items:center;">
<a href="/logs.html" style="color:#00ff88; text-decoration:none; font-size:14px; border:1px solid #00ff88; padding:6px 14px; border-radius:6px;">📋 Журнал</a>
<div id="server-time" class="header-time">--:--:--</div>
<div id="audio-indicator" style="font-size:18px; padding:4px 12px; border-radius:6px; background:rgba(0,0,0,0.4); border:1px solid #333; min-width:50px; text-align:center;">🔇</div>
<button id="camToggle" class="cam-btn">📷 КАМЕРА</button>
</div>
</div>
<div class="layout">
@@ -62,11 +64,6 @@
</div>
</div>
<div class="card wide">
<h3>ГРАФИК КАНАЛА 1</h3>
<canvas id="signalChart" height="120"></canvas>
</div>
<div class="card wide">
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
<div class="accordion-body" id="history-body">
@@ -81,7 +78,7 @@
<div class="resizer" id="dragBar"></div>
<!-- RIGHT: камера -->
<div class="panel right" id="rightPanel">
<div class="panel right hidden" id="rightPanel">
<div class="cam-modal">
<div class="cam-header">
<span>Видео с камеры</span>

View File

@@ -1,17 +1,38 @@
// app.js (оркестратор)
import { fetchHealth, fetchHistory } from "./data.js";
import { state, addSignals, resetBufferTracking, updatePeakHolder, forceResetPeak, getPeakInfo } from "./state.js";
import { initDOM, setStatus, setMeta, setBars, setSignal, DOM } from "./ui.js";
import {
fetchHealth,
fetchHistory
} from "./data.js";
import {
state,
addSignals,
resetBufferTracking,
updatePeakHolder,
updateSignalPeakHolder,
setPeakHolderListener
} from "./state.js";
import {
initDOM,
setServerTime,
setStatus,
setMeta,
setBars,
setSignal,
setAudioIndicator,
DOM
} from "./ui.js";
import { initAccordion } from "./accordion.js";
import { drawChart } from "./chart.js";
import { initResize } from "./layout.js";
import { initCamera } from "./camera.js";
import { audioEngine } from "./audio.js";
let lastChart: number = 0;
let connectionLost: boolean = false;
let consecutiveErrors: number = 0;
const MAX_ERRORS: number = 3;
let pollTimer: number | null = null;
let lastSignalLevel: number = 0;
// ================= INIT =================
window.addEventListener("DOMContentLoaded", () => {
@@ -19,6 +40,28 @@ window.addEventListener("DOMContentLoaded", () => {
initAccordion();
initResize();
initCamera();
// Инициализация звука при первом клике
document.addEventListener('click', () => {
audioEngine.updateLevel(0);
}, { once: true });
setPeakHolderListener(() => {
if (!connectionLost) {
setBars(state.peakValue);
setSignal(state.peakSignalLevel);
const signalLevel = state.peakSignalLevel;
if (signalLevel !== lastSignalLevel) {
lastSignalLevel = signalLevel;
audioEngine.updateLevel(signalLevel);
// Обновляем индикатор звука
const isActive = signalLevel > 0;
setAudioIndicator(isActive, signalLevel);
}
}
});
});
document.addEventListener("keydown", (e: KeyboardEvent) => {
@@ -45,6 +88,27 @@ document.addEventListener("keydown", (e: KeyboardEvent) => {
rightPanel.style.flex = `0 0 ${total - leftWidth}px`;
}
break;
case 'm':
// Toggle звука
if (lastSignalLevel > 0) {
// Временно отключаем
audioEngine.updateLevel(0);
setAudioIndicator(false);
console.log('[Audio] Звук отключён (M)');
// Сохраняем уровень для восстановления
window._mutedLevel = lastSignalLevel;
lastSignalLevel = 0;
} else {
// Восстанавливаем уровень
const level = window._mutedLevel || state.peakSignalLevel;
if (level > 0) {
audioEngine.updateLevel(level);
lastSignalLevel = level;
setAudioIndicator(true, level);
console.log('[Audio] Звук включён (M)');
}
}
break;
}
});
@@ -58,6 +122,10 @@ async function update(): Promise<void> {
consecutiveErrors = 0;
if (health.server_time) {
setServerTime(health.server_time);
}
if (connectionLost) {
console.log("[app] Соединение восстановлено");
}
@@ -71,6 +139,11 @@ async function update(): Promise<void> {
setStatus("Нет связи", true);
if (!connectionLost) resetBufferTracking();
connectionLost = true;
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
}
const arr = Array.isArray(history.bytes) ? history.bytes : [];
@@ -88,11 +161,24 @@ async function update(): Promise<void> {
if (isOnline) {
const displayValue = updatePeakHolder(percent);
setBars(displayValue);
setSignal(level);
const displaySignalLevel = updateSignalPeakHolder(level);
setSignal(displaySignalLevel);
const currentSignalLevel = state.peakSignalLevel;
if (currentSignalLevel !== lastSignalLevel) {
lastSignalLevel = currentSignalLevel;
audioEngine.updateLevel(currentSignalLevel);
setAudioIndicator(currentSignalLevel > 0, currentSignalLevel);
}
} else {
// Сервер НЕ на связи - сбрасываем индикаторы в 0 и делаем серыми
setBars(0, true); // true = режим "нет соединения"
setSignal(0, true); // true = режим "нет соединения"
setBars(0, true);
setSignal(0, true);
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
}
if (DOM.lastByte) {
@@ -122,10 +208,17 @@ async function update(): Promise<void> {
if (consecutiveErrors === 1) console.warn("[app] Сервер недоступен, ждём...");
setStatus("СЕРВЕР НЕДОСТУПЕН", true);
setServerTime("--:--:--");
setBars(0, true);
setSignal(0, true);
resetBufferTracking();
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
if (DOM.lastByte) DOM.lastByte.textContent = "--";
if (consecutiveErrors >= MAX_ERRORS && pollTimer) {
@@ -150,5 +243,15 @@ window.addEventListener("focus", () => {
}
});
window.addEventListener("beforeunload", () => {
audioEngine.dispose();
});
declare global {
interface Window {
_mutedLevel?: number;
}
}
startPolling();
update();

304
cmd/server/web/js/audio.ts Normal file
View File

@@ -0,0 +1,304 @@
// audio.ts - Звуковой движок для сигнала
export class AudioEngine {
private audioContext: AudioContext | null = null;
private oscillator: OscillatorNode | null = null;
private gainNode: GainNode | null = null;
private isPlaying: boolean = false;
private currentLevel: number = 0;
private currentFrequency: number = 800;
private fadeTimeout: number | null = null;
private readonly FADE_TIME: number = 0.05; // 50 мс для плавного старта/остановки
private readonly FREQUENCY_MAP: Record<number, number> = {
1: 800, // Уровень 1 → 800 Гц
2: 1000, // Уровень 2 → 1000 Гц
3: 1200 // Уровень 3 → 1200 Гц
};
constructor() {
// Инициализация при первом использовании
}
/**
* Инициализация AudioContext (требует взаимодействия пользователя)
*/
private initAudio(): boolean {
try {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
}
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
return this.audioContext.state === 'running';
} catch (error) {
console.warn('[Audio] Ошибка инициализации:', error);
return false;
}
}
/**
* Получение частоты для уровня
*/
private getFrequencyForLevel(level: number): number {
return this.FREQUENCY_MAP[level] || 800;
}
/**
* Обновление частоты осциллятора
*/
private updateFrequency(frequency: number): void {
if (!this.oscillator) return;
try {
// Плавное изменение частоты за 50 мс
const currentTime = this.audioContext?.currentTime || 0;
this.oscillator.frequency.exponentialRampToValueAtTime(
frequency,
currentTime + this.FADE_TIME
);
this.currentFrequency = frequency;
console.log(`[Audio] Частота: ${frequency} Гц`);
} catch (error) {
// Если не удалось плавно, устанавливаем сразу
this.oscillator.frequency.value = frequency;
this.currentFrequency = frequency;
}
}
/**
* Создание звукового графа
*/
private createSoundGraph(level: number): void {
if (!this.audioContext) return;
// Останавливаем старый звук
this.stopSound();
const frequency = this.getFrequencyForLevel(level);
this.currentFrequency = frequency;
// Создаём осциллятор
this.oscillator = this.audioContext.createOscillator();
this.oscillator.type = 'sine';
this.oscillator.frequency.value = frequency;
// Создаём усилитель
this.gainNode = this.audioContext.createGain();
this.gainNode.gain.value = 0; // Начинаем с тишины
// Подключаем: осциллятор → усилитель → выход
this.oscillator.connect(this.gainNode);
this.gainNode.connect(this.audioContext.destination);
// Запускаем осциллятор
this.oscillator.start();
this.isPlaying = true;
console.log(`[Audio] Звук создан, частота: ${frequency} Гц, уровень: ${level}`);
}
/**
* Обновление уровня сигнала
* @param level - уровень сигнала (0-3)
*/
public updateLevel(level: number): void {
// Инициализация при первом вызове
if (!this.audioContext) {
if (!this.initAudio()) {
return;
}
}
// Если AudioContext не готов, пробуем восстановить
if (this.audioContext?.state !== 'running') {
this.audioContext?.resume();
return;
}
// Приводим уровень к числу
const newLevel = Math.max(0, Math.min(3, Math.round(level)));
// Если уровень не изменился — ничего не делаем
if (newLevel === this.currentLevel) {
return;
}
const oldLevel = this.currentLevel;
this.currentLevel = newLevel;
// Получаем частоту для нового уровня
const newFrequency = this.getFrequencyForLevel(newLevel);
// Если уровень > 0 и звук не создан — создаём
if (newLevel > 0 && !this.oscillator) {
this.createSoundGraph(newLevel);
// Устанавливаем громкость после создания
this.setVolume(newLevel);
return;
}
// Если уровень 0 — выключаем звук
if (newLevel === 0) {
this.fadeOut();
return;
}
// Если звук не играет — включаем заново
if (!this.isPlaying || !this.oscillator) {
this.createSoundGraph(newLevel);
this.setVolume(newLevel);
return;
}
// Изменяем частоту, если она изменилась
if (newFrequency !== this.currentFrequency) {
this.updateFrequency(newFrequency);
}
// Обновляем громкость
this.setVolume(newLevel);
console.log(`[Audio] Уровень: ${oldLevel}${newLevel}, Частота: ${newFrequency} Гц`);
}
/**
* Установка громкости для уровня
*/
private setVolume(level: number): void {
if (!this.gainNode || !this.audioContext) return;
// Расчёт громкости: уровень 1 = 10%, 2 = 25%, 3 = 45%
const volumeMap: Record<number, number> = {
1: 0.10,
2: 0.25,
3: 0.45
};
const targetVolume = volumeMap[level] || 0;
try {
const currentGain = this.gainNode.gain.value;
// Если звук выключен или очень тихий, включаем сразу
if (currentGain < 0.01) {
this.gainNode.gain.setValueAtTime(targetVolume, this.audioContext.currentTime);
} else {
// Плавное изменение за 50 мс
this.gainNode.gain.exponentialRampToValueAtTime(
Math.max(targetVolume, 0.001),
this.audioContext.currentTime + this.FADE_TIME
);
}
} catch (error) {
// fallback
this.gainNode.gain.value = targetVolume;
}
}
/**
* Плавное затухание и остановка
*/
private fadeOut(): void {
if (!this.gainNode || !this.audioContext) return;
// Отменяем старый таймер
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
try {
// Плавно убавляем громкость за 100 мс
const currentTime = this.audioContext.currentTime;
this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2);
} catch (error) {
// fallback
this.gainNode.gain.value = 0;
}
// Останавливаем звук после затухания
this.fadeTimeout = window.setTimeout(() => {
this.stopSound();
this.fadeTimeout = null;
}, this.FADE_TIME * 2 * 1000 + 50);
console.log('[Audio] Затухание...');
}
/**
* Немедленная остановка звука
*/
private stopSound(): void {
if (this.oscillator) {
try {
this.oscillator.stop();
this.oscillator.disconnect();
} catch (e) {
// Игнорируем ошибки при остановке
}
this.oscillator = null;
}
if (this.gainNode) {
try {
this.gainNode.disconnect();
} catch (e) {
// Игнорируем ошибки
}
this.gainNode = null;
}
this.isPlaying = false;
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
console.log('[Audio] Звук остановлен');
}
/**
* Проверка, активен ли звук
*/
public isActive(): boolean {
return this.isPlaying && this.currentLevel > 0;
}
/**
* Получение текущей частоты
*/
public getCurrentFrequency(): number {
return this.currentFrequency;
}
/**
* Получение текущего уровня
*/
public getCurrentLevel(): number {
return this.currentLevel;
}
/**
* Полная остановка и освобождение ресурсов
*/
public dispose(): void {
this.stopSound();
if (this.audioContext) {
try {
this.audioContext.close();
} catch (e) {
// Игнорируем
}
this.audioContext = null;
}
this.currentLevel = 0;
this.currentFrequency = 800;
console.log('[Audio] Ресурсы освобождены');
}
}
// Экспортируем одиночный экземпляр
export const audioEngine = new AudioEngine();

View File

@@ -18,8 +18,6 @@ export function initCamera(): void {
camImage = document.getElementById("camImage") as HTMLImageElement;
crosshair = document.getElementById("crosshair");
// Проверяем доступность потока
checkStreamAvailability();
const btn = document.getElementById("camToggle");
@@ -37,6 +35,7 @@ export function initCamera(): void {
// Показываем перекрестие
if (crosshair) {
crosshair.style.display = "block";
crosshair.style.opacity = "0.5";
}
if (camImage) {
@@ -114,29 +113,6 @@ export function initCamera(): void {
btn?.addEventListener("click", toggle);
// Проверка доступности камеры
async function checkStreamAvailability(): Promise<void> {
try {
const response = await fetch('/api/stream');
const data: StreamResponse = await response.json();
if (data.available && data.cam) {
console.log("[camera] stream available:", data.source);
// Автозапуск если камера доступна
if (!isOpen) {
open();
}
} else {
console.log("[camera] stream not available");
if (crosshair) {
crosshair.style.display = 'none';
}
}
} catch (error) {
console.log("[camera] check failed:", error);
}
}
// Мониторинг состояния потока
function startStreamMonitoring(): void {
stopStreamMonitoring();

View File

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

View File

@@ -5,13 +5,23 @@ interface AppState {
signalHistory: number[];
lastBufferSize: number;
// Peak Holder параметры
peakValue: number; // Текущее удерживаемое пиковое значение
currentValue: number; // Текущее реальное значение (без сглаживания)
peakTimer: number | null; // Таймер для сброса пика
// Peak Holder
peakValue: number; // Текущее удерживаемое пиковое значение амплитуды
currentValue: number; // Текущее реальное значение амплитуды
peakTimer: number | null; // Таймер для сброса пика амплитуды
peakHoldTime: number; // Время удержания пика в миллисекундах
// Peak Holder для уровня сигнала
peakSignalLevel: number;
currentSignalLevel: number;
signalPeakTimer: number | null;
signalPeakHoldTime: number;
hasPeak: boolean;
}
type PeakHolderListener = () => void;
export const state: AppState = {
maxPoints: 1000,
signalHistory: [],
@@ -20,14 +30,66 @@ export const state: AppState = {
peakValue: 0,
currentValue: 0,
peakTimer: null,
peakHoldTime: 3000, // миллисекунды удержания пика
peakHoldTime: 3000, // 3 секунды
peakSignalLevel: 0,
currentSignalLevel: 0,
signalPeakTimer: null,
signalPeakHoldTime: 3000,
hasPeak: false,
};
/**
* Основная функция peak holder
* @param target - новое значение (0-63)
* @returns значение для отображения (пик, если таймер активен)
*/
let peakHolderListener: PeakHolderListener | null = null;
/** Обновление UI при сбросе peak holder по таймеру (между опросами API). */
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
peakHolderListener = listener;
}
function notifyPeakHolderChange(): void {
if (peakHolderListener) {
peakHolderListener();
}
}
function scheduleAmplitudePeakRelease(): void {
// Сбрасываем старый таймер
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
// Запускаем новый таймер на 3 секунды
state.peakTimer = window.setTimeout(() => {
state.peakValue = state.currentValue;
state.peakTimer = null;
state.hasPeak = false;
console.log(`[Peak] Сброс пика до: ${state.peakValue}`);
notifyPeakHolderChange();
}, state.peakHoldTime);
}
function scheduleSignalPeakRelease(): void {
// Сбрасываем старый таймер
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
// Запускаем новый таймер на 3 секунды
state.signalPeakTimer = window.setTimeout(() => {
// Сбрасываем пик к текущему уровню
state.peakSignalLevel = state.currentSignalLevel;
state.signalPeakTimer = null;
state.hasPeak = false;
console.log(`[SignalPeak] Сброс пика до: ${state.peakSignalLevel}`);
notifyPeakHolderChange();
}, state.signalPeakHoldTime);
}
// ========== ФУНКЦИИ ДЛЯ АМПЛИТУДЫ ==========
export function updatePeakHolder(target: number): number {
// Обновляем текущее значение
state.currentValue = target;
@@ -36,80 +98,69 @@ export function updatePeakHolder(target: number): number {
if (target > state.peakValue) {
// Устанавливаем новый пик
state.peakValue = target;
// Сбрасываем и перезапускаем таймер
resetPeakTimer();
startPeakTimer();
}
// Возвращаем текущий пик для отображения
state.hasPeak = true;
scheduleAmplitudePeakRelease();
} else if (target < state.peakValue && state.peakTimer === null) {
// Пик выше текущего значения, но таймер пропал — перезапускаем удержание
scheduleAmplitudePeakRelease();
} // Возвращаем текущий пик
return state.peakValue;
}
/**
* Запуск таймера сброса пика
*/
function startPeakTimer(): void {
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
// ========== ФУНКЦИИ ДЛЯ УРОВНЯ СИГНАЛА ==========
export function updateSignalPeakHolder(level: number): number {
// Обновляем текущее значение
state.currentSignalLevel = level;
// Если новый уровень больше пика — устанавливаем новый пик
if (level > state.peakSignalLevel) {
state.peakSignalLevel = level;
state.hasPeak = true;
scheduleSignalPeakRelease();
} else if (level < state.peakSignalLevel && state.signalPeakTimer === null) {
scheduleSignalPeakRelease();
}
state.peakTimer = window.setTimeout(() => {
resetPeak();
}, state.peakHoldTime);
// Возвращаем текущий пик
return state.peakSignalLevel;
}
/**
* Сброс таймера пика
*/
function resetPeakTimer(): void {
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
export function forceResetSignalPeak(): void {
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
state.peakSignalLevel = 0;
state.currentSignalLevel = 0;
state.hasPeak = false;
}
export function setSignalPeakHoldTime(ms: number): void {
state.signalPeakHoldTime = ms;
}
export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } {
return {
peak: state.peakSignalLevel,
current: state.currentSignalLevel,
timerActive: state.signalPeakTimer !== null,
};
}
export function forceResetPeak(): void {
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
}
/**
* Сброс пикового значения до текущего уровня
*/
function resetPeak(): void {
state.peakValue = state.currentValue;
state.peakTimer = null;
// Опционально: лог для отладки
// console.log(`[PeakHolder] Reset to: ${state.peakValue}`);
}
/**
* Принудительный сброс пика (например, при потере связи)
*/
export function forceResetPeak(): void {
resetPeakTimer();
state.peakValue = 0;
state.currentValue = 0;
state.hasPeak = false;
forceResetSignalPeak();
}
/**
* Обновление времени удержания пика
* @param ms - время в миллисекундах
*/
export function setPeakHoldTime(ms: number): void {
state.peakHoldTime = ms;
}
/**
* Получение текущего состояния пика (для отладки)
*/
export function getPeakInfo(): { peak: number; current: number; timerActive: boolean } {
return {
peak: state.peakValue,
current: state.currentValue,
timerActive: state.peakTimer !== null,
};
}
// ===== ОСТАВЛЯЕМ СУЩЕСТВУЮЩИЕ ФУНКЦИИ БЕЗ ИЗМЕНЕНИЙ =====
export function addSignal(value: number): void {
state.signalHistory.push(value);
if (state.signalHistory.length > state.maxPoints) {
@@ -137,5 +188,5 @@ export function addSignals(values: number[], bufferSize: number): void {
export function resetBufferTracking(): void {
state.lastBufferSize = 0;
forceResetPeak(); // Также сбрасываем пик при потере связи
}
forceResetPeak();
}

View File

@@ -1,5 +1,6 @@
// ui.ts (DOM слой)
export interface DOMElements {
serverTime: HTMLElement | null;
status: HTMLElement | null;
meta: HTMLElement | null;
latest: HTMLElement | null;
@@ -10,9 +11,11 @@ export interface DOMElements {
barFill: HTMLElement | null;
historyHeader: HTMLElement | null;
historyBody: HTMLElement | null;
audioIndicator: HTMLElement | null;
}
export const DOM: DOMElements = {
serverTime: null,
status: null,
meta: null,
latest: null,
@@ -23,9 +26,11 @@ export const DOM: DOMElements = {
barFill: null,
historyHeader: null,
historyBody: null,
audioIndicator: null,
};
export function initDOM(): void {
DOM.serverTime = document.getElementById("server-time");
DOM.status = document.getElementById("status");
DOM.meta = document.getElementById("meta");
DOM.latest = document.getElementById("latest");
@@ -36,6 +41,11 @@ export function initDOM(): void {
DOM.historyHeader = document.getElementById("history-header");
DOM.historyBody = document.getElementById("history-body");
DOM.bars = document.querySelectorAll(".sig-bar");
DOM.audioIndicator = document.getElementById("audio-indicator");
}
export function setServerTime(v: string): void {
if (DOM.serverTime) DOM.serverTime.textContent = v;
}
export function setStatus(v: string, isError: boolean = false): void {
@@ -100,3 +110,22 @@ export function setSignal(level: number, noConnection: boolean = false): void {
bars[2].classList.remove("dim");
}
}
// ФУНКЦИЮ ДЛЯ ИНДИКАТОРА ЗВУКА
export function setAudioIndicator(active: boolean, level: number = 0): void {
const el = DOM.audioIndicator;
if (!el) return;
if (active && level > 0) {
el.textContent = `🔊 ${level}`;
el.style.color = '#00ff88';
el.style.opacity = '1';
// Анимация пульсации
el.style.animation = 'pulse 1s ease-in-out infinite';
} else {
el.textContent = '🔇';
el.style.color = '#666';
el.style.opacity = '0.5';
el.style.animation = 'none';
}
}

373
cmd/server/web/logs.html Normal file
View File

@@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Журнал логов</title>
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/css/style.css">
<style>
/* Дополнительные стили для журнала */
.logs-container {
display: flex;
gap: 20px;
height: calc(100vh - 120px);
}
.logs-sidebar {
flex: 0 0 280px;
border-right: 1px solid #00ff88;
padding-right: 16px;
overflow-y: auto;
}
.logs-content {
flex: 1;
overflow-y: auto;
}
.log-file-item {
padding: 8px 12px;
margin: 4px 0;
cursor: pointer;
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.2s;
}
.log-file-item:hover {
background: rgba(0,255,136,0.1);
border-color: #00ff88;
}
.log-file-item.active {
background: rgba(0,255,136,0.2);
border-color: #00ff88;
}
.log-file-item .file-name {
font-size: 13px;
}
.log-file-item .file-info {
font-size: 11px;
opacity: 0.6;
}
.log-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.log-stat-card {
padding: 12px;
border: 1px solid #00ff88;
border-radius: 4px;
text-align: center;
}
.log-stat-card .value {
font-size: 24px;
font-weight: bold;
}
.log-stat-card .label {
font-size: 11px;
opacity: 0.6;
}
.data-table {
width: 100%;
font-size: 12px;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid #00ff88;
position: sticky;
top: 0;
background: #050805;
}
.data-table td {
padding: 4px 8px;
border-bottom: 1px solid rgba(0,255,136,0.1);
}
.data-table tr:hover {
background: rgba(0,255,136,0.05);
}
.strength-0 { color: #666; }
.strength-1 { color: #88ff88; }
.strength-2 { color: #ffaa44; }
.strength-3 { color: #ff4444; }
.events-list {
font-family: monospace;
font-size: 13px;
line-height: 1.6;
}
.events-list .event-time {
opacity: 0.5;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab-btn {
padding: 6px 16px;
background: transparent;
color: #00ff88;
border: 1px solid #00ff88;
border-radius: 4px;
cursor: pointer;
font-family: monospace;
}
.tab-btn.active {
background: rgba(0,255,136,0.2);
}
.tab-btn:hover {
background: rgba(0,255,136,0.1);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<div class="header-bar">
<h1>📋 ЖУРНАЛ ЛОГОВ</h1>
<div id="server-time" class="header-time">--:--:--</div>
<a href="/" style="color:#00ff88; text-decoration:none; font-size:14px;">На главную</a>
</div>
<div class="logs-container">
<div class="logs-sidebar">
<h3 style="margin-top:0;">📁 Файлы данных</h3>
<div id="fileList"></div>
</div>
<div class="logs-content">
<div class="tabs">
<button class="tab-btn active" data-tab="data">📊 Данные</button>
<button class="tab-btn" data-tab="stats">📈 Статистика</button>
<button class="tab-btn" data-tab="events">📋 События</button>
</div>
<div id="tab-data" class="tab-content active">
<div id="logStats" class="log-stats"></div>
<div id="chartContainer" style="margin-bottom:16px;">
<canvas id="logChart" height="200"></canvas>
</div>
<div id="dataTableContainer" style="max-height:400px; overflow-y:auto;"></div>
</div>
<div id="tab-stats" class="tab-content">
<div id="statsContent">Выберите файл для просмотра статистики</div>
</div>
<div id="tab-events" class="tab-content">
<div id="eventsContent" class="events-list">Загрузка...</div>
</div>
</div>
</div>
<script type="module">
import { drawChart } from '/dist/chart.js';
// Состояние
let currentFile = null;
let currentData = null;
let currentEvents = [];
// DOM элементы
const fileList = document.getElementById('fileList');
const logStats = document.getElementById('logStats');
const dataTableContainer = document.getElementById('dataTableContainer');
const logChart = document.getElementById('logChart');
const eventsContent = document.getElementById('eventsContent');
// Загрузка списка файлов
async function loadFileList() {
try {
const response = await fetch('/api/log/files');
const files = await response.json();
fileList.innerHTML = '';
files.forEach(file => {
const div = document.createElement('div');
div.className = 'log-file-item';
if (currentFile === file.name) div.classList.add('active');
div.innerHTML = `
<div class="file-name">${file.name}</div>
<div class="file-info">${file.time} | ${(file.size/1024).toFixed(1)} KB</div>
`;
div.addEventListener('click', () => loadFileData(file.name));
fileList.appendChild(div);
});
// Автоматически загружаем первый файл
if (files.length > 0 && !currentFile) {
loadFileData(files[0].name);
}
} catch (error) {
console.error('Error loading file list:', error);
}
}
// Загрузка данных файла
async function loadFileData(filename) {
currentFile = filename;
// Обновляем активный элемент в списке
document.querySelectorAll('.log-file-item').forEach(el => el.classList.remove('active'));
const items = document.querySelectorAll('.log-file-item');
for (const item of items) {
if (item.textContent.includes(filename)) {
item.classList.add('active');
break;
}
}
try {
const response = await fetch(`/api/log/data?file=${encodeURIComponent(filename)}`);
const data = await response.json();
currentData = data;
renderData(data);
} catch (error) {
console.error('Error loading data:', error);
}
}
// Отрисовка данных
function renderData(data) {
const samples = data.samples || [];
// Статистика
if (samples.length > 0) {
const values = samples.map(s => s.value);
const counts = samples.map(s => s.count);
const max = Math.max(...values);
const min = Math.min(...values);
const avg = values.reduce((a,b) => a + b, 0) / values.length;
const totalObjects = counts.reduce((a,b) => a + b, 0);
logStats.innerHTML = `
<div class="log-stat-card">
<div class="value">${samples.length}</div>
<div class="label">Всего точек</div>
</div>
<div class="log-stat-card">
<div class="value">${max}</div>
<div class="label">Максимум</div>
</div>
<div class="log-stat-card">
<div class="value">${avg.toFixed(1)}</div>
<div class="label">Среднее</div>
</div>
<div class="log-stat-card">
<div class="value">${totalObjects}</div>
<div class="label">Объектов всего</div>
</div>
`;
}
// График
if (logChart && samples.length > 0) {
const history = samples.map(s => s.count);
drawChart(logChart, history);
}
// Таблица — только Время, Объектов, Сила
if (samples.length > 0) {
let html = `<table class="data-table">
<thead>
<tr>
<th>Время</th>
<th>Объектов</th>
<th>Сила</th>
</tr>
</thead>
<tbody>`;
const displaySamples = samples.slice(-200);
displaySamples.forEach(s => {
const strengthClass = `strength-${s.strength}`;
html += `<tr>
<td>${s.time}</td>
<td>${s.count}</td>
<td class="${strengthClass}">${s.strength_name}</td>
</tr>`;
});
html += '</tbody></table>';
dataTableContainer.innerHTML = html;
} else {
dataTableContainer.innerHTML = '<p>Нет данных</p>';
}
}
// Загрузка событий
async function loadEvents() {
try {
const response = await fetch('/api/log/events?limit=200');
const data = await response.json();
currentEvents = data.events || [];
let html = '';
currentEvents.forEach(e => {
html += `<div><span class="event-time">[${e.time}]</span> ${e.event}</div>`;
});
eventsContent.innerHTML = html || 'Нет событий';
} catch (error) {
eventsContent.innerHTML = 'Ошибка загрузки событий';
}
}
// Переключение табов
document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn[data-tab]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tabName = btn.dataset.tab;
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.getElementById(`tab-${tabName}`).classList.add('active');
if (tabName === 'events') {
loadEvents();
}
});
});
// Инициализация
loadFileList();
// Обновление времени
function updateTime() {
const now = new Date();
document.getElementById('server-time').textContent = now.toLocaleTimeString();
}
setInterval(updateTime, 1000);
updateTime();
</script>
</body>
</html>

38
internal/logger/config.go Normal file
View File

@@ -0,0 +1,38 @@
package logger
import "time"
// Config содержит настройки для всех компонентов логирования
type Config struct {
// Retention настройки
RetentionHours int
RetentionMB int
RetentionIntervalMin time.Duration
// Мониторинг тишины
Silence5Min time.Duration
Silence10Min time.Duration
WatchdogIntervalMin time.Duration
// Анти-спам и интервалы
AlertCooldownSec time.Duration
HumanLogIntervalSec time.Duration
// Буфер
BufferSize int
}
// DefaultConfig возвращает конфигурацию по умолчанию
func DefaultConfig() *Config {
return &Config{
RetentionHours: 48,
RetentionMB: 5000,
RetentionIntervalMin: 15 * time.Minute,
Silence5Min: 5 * time.Minute,
Silence10Min: 10 * time.Minute,
WatchdogIntervalMin: 1 * time.Minute,
AlertCooldownSec: 10 * time.Second,
HumanLogIntervalSec: 5 * time.Second,
BufferSize: 10240,
}
}

View File

@@ -6,20 +6,34 @@ import (
)
type Monitor struct {
eventLogger *EventLogger
lastWrite time.Time
mu sync.RWMutex
eventLogger *EventLogger
lastWrite time.Time
mu sync.RWMutex
silence5Min time.Duration
silence10Min time.Duration
watchdogInterval time.Duration
}
func NewMonitor(eventLogger *EventLogger) *Monitor {
func NewMonitor(eventLogger *EventLogger, config *Config) *Monitor {
m := &Monitor{
eventLogger: eventLogger,
lastWrite: time.Now(),
eventLogger: eventLogger,
lastWrite: time.Now(),
silence5Min: config.Silence5Min,
silence10Min: config.Silence10Min,
watchdogInterval: config.WatchdogIntervalMin,
}
go m.watchdogLoop()
return m
}
// SetSilenceThresholds позволяет изменить пороги тишины после создания
func (m *Monitor) SetSilenceThresholds(silence5Min, silence10Min time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.silence5Min = silence5Min
m.silence10Min = silence10Min
}
func (m *Monitor) RecordWrite() {
m.mu.Lock()
m.lastWrite = time.Now()
@@ -27,15 +41,19 @@ func (m *Monitor) RecordWrite() {
}
func (m *Monitor) watchdogLoop() {
ticker := time.NewTicker(1 * time.Minute)
ticker := time.NewTicker(m.watchdogInterval)
defer ticker.Stop()
for range ticker.C {
m.mu.RLock()
silence := time.Since(m.lastWrite)
silence5Min := m.silence5Min
silence10Min := m.silence10Min
m.mu.RUnlock()
if silence > 10*time.Minute {
if silence > silence10Min {
m.eventLogger.Event("ТИШИНА_10МИН")
} else if silence > 5*time.Minute {
} else if silence > silence5Min {
m.eventLogger.Event("ТИШИНА_5МИН")
}
}

View File

@@ -10,9 +10,10 @@ import (
)
type Retention struct {
maxAgeHours int
maxSizeBytes int64
eventLogger *EventLogger
maxAgeHours int
maxSizeBytes int64
eventLogger *EventLogger
stopCh chan struct{}
}
func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Retention {
@@ -20,15 +21,27 @@ func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Ret
maxAgeHours: maxAgeHours,
maxSizeBytes: int64(maxSizeMB) * 1024 * 1024,
eventLogger: eventLogger,
stopCh: make(chan struct{}),
}
}
func (r *Retention) Start() {
// Запускаем проверку каждые 15 минут
ticker := time.NewTicker(15 * time.Minute)
r.StartWithInterval(15 * time.Minute)
}
func (r *Retention) StartWithInterval(interval time.Duration) {
// Запускаем проверку с указанным интервалом
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
r.Cleanup()
for {
select {
case <-ticker.C:
r.Cleanup()
case <-r.stopCh:
ticker.Stop()
return
}
}
}()
@@ -38,7 +51,16 @@ func (r *Retention) Start() {
})
}
func (r *Retention) Stop() {
close(r.stopCh)
}
func (r *Retention) Cleanup() {
// Если оба лимита отключены, ничего не делаем
if r.maxAgeHours <= 0 && r.maxSizeBytes <= 0 {
return
}
// Получаем директорию с данными
dataDir, err := GetDataLogsDir()
if err != nil {
@@ -52,11 +74,17 @@ func (r *Retention) Cleanup() {
r.eventLogger.Event("ЗАПУЩЕНА_ОЧИСТКАОГОВ")
}
// 1. Удаляем старые файлы
deletedByAge := r.cleanByAge(dataDir)
// 1. Удаляем старые файлы (если включено)
deletedByAge := 0
if r.maxAgeHours > 0 {
deletedByAge = r.cleanByAge(dataDir)
}
// 2. Проверяем общий размер и удаляем самые старые если превышен лимит
deletedBySize := r.cleanBySize(dataDir)
// 2. Проверяем общий размер и удаляем самые старые если превышен лимит (если включено)
deletedBySize := 0
if r.maxSizeBytes > 0 {
deletedBySize = r.cleanBySize(dataDir)
}
if r.eventLogger != nil && (deletedByAge > 0 || deletedBySize > 0) {
r.eventLogger.Event("УДАЛЕНОАЙЛОВ")

View File

@@ -8,23 +8,30 @@ import (
)
type RotatingLogger struct {
dataLogger *DataLogger
currentHour int
baseDir string
mu sync.Mutex
eventLogger *EventLogger
dataLogger *DataLogger
currentHour int
baseDir string
mu sync.Mutex
eventLogger *EventLogger
checkInterval time.Duration
}
func NewRotatingLogger(eventLogger *EventLogger) (*RotatingLogger, error) {
func NewRotatingLogger(eventLogger *EventLogger, checkInterval time.Duration) (*RotatingLogger, error) {
// Получаем директорию для бинарных данных
dataDir, err := GetDataLogsDir()
if err != nil {
return nil, err
}
// Интервал проверки по умолчанию
if checkInterval == 0 {
checkInterval = 1 * time.Minute
}
r := &RotatingLogger{
baseDir: dataDir,
eventLogger: eventLogger,
baseDir: dataDir,
eventLogger: eventLogger,
checkInterval: checkInterval,
}
if err := r.rotate(); err != nil {
@@ -87,7 +94,9 @@ func (r *RotatingLogger) Write(s Sample) {
}
func (r *RotatingLogger) rotationLoop() {
ticker := time.NewTicker(1 * time.Minute)
ticker := time.NewTicker(r.checkInterval)
defer ticker.Stop()
for range ticker.C {
r.rotate()
}

View File

@@ -21,6 +21,10 @@ type PipeReader struct {
lastData logger.GPIOData
lastLogTime time.Time
lastAlertTime map[byte]time.Time
// Настройки
alertCooldownSec time.Duration
humanLogIntervalSec time.Duration
}
func NewPipeReader(
@@ -29,14 +33,17 @@ func NewPipeReader(
humanLogger *logger.HumanLogger,
monitor *logger.Monitor,
eventLog *logger.EventLogger,
config *logger.Config,
) *PipeReader {
return &PipeReader{
buf: buf,
dataLogger: dataLogger,
humanLogger: humanLogger,
monitor: monitor,
eventLog: eventLog,
lastAlertTime: make(map[byte]time.Time),
buf: buf,
dataLogger: dataLogger,
humanLogger: humanLogger,
monitor: monitor,
eventLog: eventLog,
lastAlertTime: make(map[byte]time.Time),
alertCooldownSec: config.AlertCooldownSec,
humanLogIntervalSec: config.HumanLogIntervalSec,
}
}
@@ -100,7 +107,7 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
})
}
// 3. Human-readable лог (при изменении состояния или раз в 5 секунд)
// 3. Human-readable лог (при изменении состояния или раз в N секунд)
if pr.humanLogger != nil {
shouldLog := false
@@ -109,8 +116,8 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
shouldLog = true
}
// Или если прошло больше 5 секунд с последнего лога
if now.Sub(pr.lastLogTime) >= 5*time.Second {
// Или если прошло больше N секунд с последнего лога
if now.Sub(pr.lastLogTime) >= pr.humanLogIntervalSec {
shouldLog = true
}
@@ -149,10 +156,10 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
}
func (pr *PipeReader) handleAlert(data logger.GPIOData, timestamp time.Time) {
// Anti-spam: не чаще 1 алерта в 10 секунд для одинакового количества
// Anti-spam: не чаще 1 алерта в N секунд для одинакового количества
key := data.Count
if last, exists := pr.lastAlertTime[key]; exists {
if timestamp.Sub(last) < 10*time.Second {
if timestamp.Sub(last) < pr.alertCooldownSec {
return
}
}

View File

@@ -9,8 +9,15 @@ User=user
Group=user
WorkingDirectory=/home/user/temp/golang
# Запуск сервера
ExecStart=/home/user/temp/golang/build/gpio-monitor-server -pipe /tmp/gpio_pipe
# Запуск сервера с параметрами для 72-часового хранения
ExecStart=/home/user/temp/golang/build/gpio-monitor-server \
-pipe /tmp/gpio_pipe \
-retention-hours=72 \
-retention-mb=2000 \
-retention-interval=60 \
-rotation-check-interval=1 \
-buffer-size=10240 \
-human-log-interval=5
# Перезапуск при падении
Restart=on-failure
@@ -26,4 +33,4 @@ StandardError=journal
SyslogIdentifier=gpio-monitor
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target