Доработка документации и вывода сообщений при ошибках
This commit is contained in:
40
README.md
40
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
|
||||
|
||||
Открывайте в браузере:
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user