diff --git a/README.md b/README.md index fe9fc72..745ba52 100644 --- a/README.md +++ b/README.md @@ -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 Открывайте в браузере: diff --git a/internal/audio/monitor.go b/internal/audio/monitor.go index 403e22c..e4eab9a 100644 --- a/internal/audio/monitor.go +++ b/internal/audio/monitor.go @@ -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() } \ No newline at end of file