Доработка документации и вывода сообщений при ошибках

This commit is contained in:
Maxim
2026-07-13 09:35:42 +03:00
parent 1b305500a8
commit 48aa91b8cd
2 changed files with 132 additions and 41 deletions

View File

@@ -268,6 +268,46 @@ sudo nft add rule inet filter input iifname "wlan0" tcp dport 8080 accept
---
## Ротация и хранение логов (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
Открывайте в браузере:

View File

@@ -19,6 +19,7 @@ type Monitor struct {
mu sync.RWMutex
currentLevel int
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewMonitor() *Monitor {
@@ -35,6 +36,17 @@ func (m *Monitor) Start() error {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
if err := m.startCapture(ctx); err != nil {
cancel()
return err
}
return nil
}
// startCapture запускает arecord и горутину чтения потока. Может вызываться
// повторно при пересборке пайпа после сбоя (см. handleCrash).
func (m *Monitor) startCapture(ctx context.Context) error {
// Используем гибкий поиск USB устройств
device := m.findUSBDevice()
log.Printf("Используем аудиоустройство: %s", device)
@@ -48,56 +60,17 @@ func (m *Monitor) Start() error {
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return err
}
if err := cmd.Start(); err != nil {
cancel()
return err
}
log.Printf("Аудио-монитор запущен с устройством: %s", device)
go func() {
buffer := make([]byte, 3200)
for {
select {
case <-ctx.Done():
return
default:
_, err := io.ReadFull(stdout, buffer)
if err != nil {
log.Printf("Ошибка чтения аудио: %v", err)
time.Sleep(2 * time.Second)
continue
}
samplesCount := len(buffer) / 2
samples := make([]int16, samplesCount)
reader := bytes.NewReader(buffer)
if err := binary.Read(reader, binary.LittleEndian, &samples); err != nil {
continue
}
var sum float64
for _, sample := range samples {
val := float64(sample)
sum += val * val
}
rms := math.Sqrt(sum / float64(samplesCount))
level := int((rms / 32768.0) * 100 * 4)
if level > 100 {
level = 100
}
m.mu.Lock()
m.currentLevel = level
m.mu.Unlock()
}
}
}()
m.wg.Add(1)
go m.readLoop(ctx, stdout)
go func() {
_ = cmd.Wait()
@@ -106,6 +79,83 @@ func (m *Monitor) Start() error {
return nil
}
func (m *Monitor) readLoop(ctx context.Context, stdout io.ReadCloser) {
defer m.wg.Done()
buffer := make([]byte, 3200)
for {
select {
case <-ctx.Done():
return
default:
}
_, err := io.ReadFull(stdout, buffer)
if err != nil {
// Контекст отменён (Stop()) — пайп закрыт намеренно, тихо выходим.
if ctx.Err() != nil {
return
}
// Дескриптор закрыт (например, cmd.Wait() уже реаппнул процесс) —
// arecord умер сам по себе, пробуем восстановиться.
if strings.Contains(err.Error(), "file already closed") {
log.Printf("Аудио-пайп закрыт: %v", err)
} else {
log.Printf("Ошибка чтения аудио: %v", err)
}
m.handleCrash(ctx)
return
}
samplesCount := len(buffer) / 2
samples := make([]int16, samplesCount)
reader := bytes.NewReader(buffer)
if err := binary.Read(reader, binary.LittleEndian, &samples); err != nil {
continue
}
var sum float64
for _, sample := range samples {
val := float64(sample)
sum += val * val
}
rms := math.Sqrt(sum / float64(samplesCount))
level := int((rms / 32768.0) * 100 * 4)
if level > 100 {
level = 100
}
m.mu.Lock()
m.currentLevel = level
m.mu.Unlock()
}
}
// handleCrash пытается пересобрать пайп arecord после того, как микрофон
// отвалился на физическом уровне, не роняя всё приложение.
func (m *Monitor) handleCrash(ctx context.Context) {
if ctx.Err() != nil {
return
}
log.Println("Микрофон отключился, повторная попытка через 5 секунд...")
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
if ctx.Err() != nil {
return
}
if err := m.startCapture(ctx); err != nil {
log.Printf("Не удалось пересобрать аудио-пайп: %v", err)
}
}
func (m *Monitor) findUSBDevice() string {
cmd := exec.Command("arecord", "-l")
output, err := cmd.Output()
@@ -155,4 +205,5 @@ func (m *Monitor) Stop() {
if m.cancel != nil {
m.cancel()
}
m.wg.Wait()
}