[2026-09-15] eagle: family/tech/local-ustreamer-addon.md

This commit is contained in:
Alexey Martemyanov
2026-09-15 19:27:13 +06:00
parent 4e9b6f5b38
commit 0044f9e8b2
+123 -59
View File
@@ -1,10 +1,10 @@
---
title: "local_ustreamer — кастомный аддон камеры на t610 (JPEG по запросу)"
created: '2026-09-15'
updated: 2026-09-15 (ночь-6: камера подключена к HA — config_entries поправлен на :8090 + рестарт ядра; снесены go2rtc, file editor; найден баг кэша /frame)
updated: 2026-09-15 (ночь-7: 🔴 `restart` НЕ пересобирает образ — нужен `rebuild`; кадр обновляется, фоновый поток 5 с; transpose=1; go2rtc + file editor снесены)
type: tech
namespace: family
status: works (кадр отдаётся, обновление кадра — под вопросом, см. §«Не обновляется»)
status: WORKS — кадр обновляется раз в 5 с при активном клиенте, поворот применён, HA показывает обновляющуюся картинку
tags:
- t610
- haos
@@ -35,13 +35,27 @@ HA / браузер
│ GET http://192.168.2.176:8090/frame
local_ustreamer (Python HTTP-сервер, порт 8090)
поднимает ffmpeg ТОЛЬКО на время запроса:
│ ffmpeg -f v4l2 -i /dev/video0 -frames:v 1 -vf transpose=2 -f mjpeg -
├─ фоновый поток refresher():
│ каждые 5 с → если есть клиент → ffmpeg снимает ОДИН кадр
│ нет клиентов → ffmpeg НЕ запускается вообще
│ ffmpeg -f v4l2 -input_format mjpeg -video_size 640x480 -i /dev/video0
│ -frames:v 1 -vf transpose=1 -q:v 5 -f image2 -c:v mjpeg -
JPEG 480×640, ~19 КБ, ~4.7 с → клиент
JPEG 480×640, ~18.7 КБ → клиент
```
**Почему это лечит:** ffmpeg живёт доли секунды и только на запрос. В фоне не жрёт ничего. Никакого H.264, никакого RTSP, никаких накопленных процессов.
**Почему это лечит:** ffmpeg живёт доли секунды и только при активном клиенте. В фоне не жрёт ничего. Никакого H.264, никакого RTSP, никаких накопленных процессов.
**Замер простоя (проверено 2026-09-15, ночь-7)** — клиент закрыт:
```
health: ok clients=0 ← клиентов нет
ffmpeg: 0 процессов ← поток спит
load: 0.93 (был 3.63)
I/O pressure: 7.8% (было 28%)
```
> ✅ **Сервис в простое неактивен** — это и есть целевое поведение, а не поломка. Открытие окна камеры мгновенно поднимает ffmpeg.
---
@@ -62,6 +76,35 @@ ha apps rebuild local_ustreamer # ОБЯЗАТЕЛЬНО после правк
ha apps restart local_ustreamer
```
## 🔴 ГЛАВНЫЙ ПИТФОЛЛ: `restart` НЕ пересобирает образ (2026-09-15, ночь-7)
**Это была настоящая причина «кадр не обновляется», а не кэш Python.**
Dockerfile аддона:
```dockerfile
COPY app.py /app.py ← код впекается в ОБРАЗ при СБОРКЕ
```
Правка `/addons/ustreamer/app.py` + `POST /addons/local_ustreamer/restart` **НЕ меняет код внутри контейнера** — образ остаётся старым, в нём живёт прежний `app.py`. Все правки (и `max_age`, и `transpose`) молча не применялись, сколько бы раз ни делался restart.
**Правильный цикл правки кода аддона:**
```bash
# 1. правим локально на Mac (не sed/awk на хосте!)
# 2. проверяем синтаксис ЛОКАЛЬНО
python3 -c "import ast; ast.parse(open('app.py',encoding='utf-8').read()); print('OK')"
# 3. заливаем
scp -o ConnectTimeout=10 app.py root@192.168.2.176:/addons/ustreamer/app.py
# 4. 🔴 ПЕРЕСОБИРАЕМ ОБРАЗ (не restart!)
curl -s -X POST -H "$H" http://supervisor/addons/local_ustreamer/rebuild
# 5. ждём завершения
ha jobs info | grep -A6 app_manager_rebuild # done: true / job исчез из списка
```
**Как понять, что rebuild идёт:** `POST .../rebuild` вернул `{"result":"ok"}` — это только постановка в очередь. Повторный вызов даст `{"result":"error","message":"Another job is running for job group app_local_ustreamer"}`. Проверять прогресс — `ha jobs info` (поле `progress`, `done`). Сборка занимает **24 минуты**, `progress` часто висит на `0`.
> ⚠️ **Проверять, что код реально попал в контейнер:** после rebuild — `grep -n "refresher" /addons/ustreamer/app.py` (это хостовый путь) и сверить поведение по md5 кадров. «Restart прошёл» ≠ «код новый».
> 💡 **Симптом-маркер:** изменение кода не даёт никакого эффекта, поведение ровно прежнее → почти наверняка забыт `rebuild`.
---
## Проверка (рабочий паттерн)
@@ -79,20 +122,25 @@ file /tmp/frame.jpg # JPEG image data, 480x640
---
## ✅ Ориентация кадра — `transpose=2` ПРИМЕНЁН (2026-09-15, ночь-6)
## ✅ Ориентация кадра — `transpose=1` (2026-09-15, ночь-7)
Камера смотрит на счётчик **боком**, нужен поворот. Фильтр в `grab_frame()`:
Камера смотрит на счётчик **боком**, нужен поворот. Итоговый фильтр в `grab_frame()`:
```
БЫЛО: vf = f"transpose=1" if ROTATE in ("90", "1") else ... ← 90° по часовой (счётчик лежал на бок)
СТАЛО: vf = f"transpose=2" if ROTATE in ("90", "1") else ... ← 90° против часовой
```python
vf = f"transpose=1" if ROTATE in ("90", "1") else (
"transpose=2" if ROTATE in ("270", "-90") else "null")
```
Правка в `/addons/ustreamer/app.py``scp``ha apps restart local_ustreamer`.
| Значение `rotate` в настройках аддона | Фильтр | Что делает |
|---|---|---|
| `90` или `1` | `transpose=1` | 90° по часовой |
| `270` или `-90` | `transpose=2` | 90° против часовой |
| прочее | `null` | без поворота |
> ⚠️ Значения `transpose`: `0` = 90° CCW + вертикальный флип, `1` = 90° CW, `2` = 90° CCW, `3` = 90° CW + вертикальный флип.
> ⚠️ Итоговая проверка ориентации **не завершена**: после смены `transpose` кадры перестали обновляться (см. §«Баг кэша»), поэтому прочитать цифры счётчика на свежем кадре не удалось. **Сверить при следующем запуске.**
> 📌 Управляется переменной аддона `CAM_ROTATE`; текущий маппинг в коде — значение `90`/`1` даёт `transpose=2`, значение `270`/`-90` даёт `transpose=2`, всё прочее — `null`. ⚠️ **Ветка `270`/`-90` тоже отдаёт `transpose=2`** — это, вероятно, копипаст-баг; при необходимости развести.
> ⚠️ Значения `transpose`: `0` = 90° CCW + вертикальный флип, `1` = **90° CW**, `2` = **90° CCW**, `3` = 90° CW + вертикальный флип.
> 🔴 **Обе ветки были `transpose=2`** — копипаст-баг, из-за которого смена `rotate` на `270` ничего не меняла. Разведено 2026-09-15.
> 📌 Управляется переменной аддона `rotate` (`CAM_ROTATE`). Правка применяется **только через `rebuild`**, не `restart` (см. §ГЛАВНЫЙ ПИТФОЛЛ).
> ✅ Проверка ориентации: Alex подтвердил «поворот не туда» при `transpose=2` → выставлен `transpose=1`.
---
@@ -156,78 +204,86 @@ cp /tmp/ce_new.json "$CE"
---
## 🔴 БАГ КЭША `/frame` — найден и исправлен (2026-09-15, ночь-6)
## ✅ «Кадр не обновляется» — развязано (2026-09-15, ночь-7)
**Симптом:** кадр в HA **не обновляется** — висит один и тот же. Логи аддона чистые, сплошные `200`.
Симптом: кадр в HA **не обновляется** — висит один и тот же, логи чистые (сплошные `200`).
**Диагностический приём (рабочий):** три запроса подряд с паузой, сравнение хэшей:
**Диагностический приём (рабочий) три запроса с паузой, сравнение хэшей:**
```bash
for i in 1 2 3; do curl -s -o /tmp/f$i.jpg "http://192.168.2.176:8090/frame"; sleep 4; done
md5sum /tmp/f1.jpg /tmp/f2.jpg /tmp/f3.jpg
# одинаковый md5 = кадр из кэша, НЕ свежий
```
**Причина — в логике `get_frame()`:**
### Причина 1 (настоящая): `restart` не пересобирал образ
Правки кода не попадали в контейнер — см. §ГЛАВНЫЙ ПИТФОЛЛ. Пока это не поняли, **любые** правки логики выглядели неработающими.
### Причина 2: логика кэша `get_frame()` (тоже исправлена)
```python
if _cache["jpeg"] and (max_age is None or age < max_age):
return _cache["jpeg"], age # ← при max_age=None условие ВСЕГДА истинно
```
`/frame` вызывал `get_frame()` **без** `max_age` `max_age is None` возвращался **первый снимок навсегда**. Кэш не истекал никогда.
`/frame` вызывал `get_frame()` **без** `max_age` → возвращался первый снимок навсегда.
### Итоговая архитектура (переписана)
**Исправлено:**
```python
# get_frame(): max_age=None больше НЕ значит «отдай кэш»
if _cache["jpeg"] and max_age is not None and age < max_age:
return _cache["jpeg"], age
if max_age is None and _cache["jpeg"]:
return _cache["jpeg"], age
# ... и в обработчике:
if p == "/frame":
jpeg, age = get_frame(max_age=0) # ← всегда свежий кадр
_cache = {"jpeg": None, "ts": 0.0}
_clients = 0 # под _clients_lock
_lock = threading.Lock()
def refresher(): # 🔑 фоновый поток, daemon=True
while True:
if has_clients():
jpeg = grab_frame() # единственный запуск ffmpeg
if jpeg: под _lock _cache
time.sleep(INTERVAL) # 5 с
# в обработчиках:
/frame get_frame(max_age=INTERVAL) # из кэша, свежесть ≤5 с
/stream get_frame(max_age=INTERVAL) # из того же кэша
```
Кэш (`max_age=INTERVAL`) остался только для MJPEG-потока `/stream`.
Бэкап: `/addons/ustreamer/app.py.bak-20260915-*`.
`main()` поднимает `threading.Thread(target=refresher, daemon=True)` до `serve_forever()`.
> ⚠️ **Правка Python-файла аддона применяется только после рестарта аддона** (`ha apps rebuild` нужен при смене образа; для правки `.py` из `/addons/` достаточно `restart`).
> ⚠️ **`python3` внутри SSH-аддона НЕТ** — синтаксис проверять локально на Mac (`python3 -c "import ast; ast.parse(...)"`), затем `scp`. Не пытаться править файл sed/awk на хосте.
### ⚠️ Остаточный симптом: кадр всё ещё не обновлялся после фикса
После правки и рестарта кадры **остались одинаковыми**, а размер упал `18964 → 3964 байт` (подозрение на пустой/чёрный кадр). Проверено в тот момент:
- `/dev/video0` и `/dev/video1`**на месте**, `lsusb` видит камеру `046d:0825` (Bus 003 Device 002);
- `health``ok clients=0 device=/dev/video0`;
- `ffmpeg` в системе — **ровно 1 процесс**, CPU ~0%;
- `dmesg`: камера **сбрасывалась дважды** (`usb 3-1: reset high-speed USB device number 2 using xhci_hcd`, t=113 и t=126).
**Гипотеза оператора (не подтверждена):** при USB-сбросе `uvcvideo` держит последний буфер → ffmpeg отдаёт замороженный кадр. Отсюда же падение размера.
**Конкурирующая гипотеза (сильнее по цифрам):** хост под **давлением по I/O и памяти**:
**Что это дало (проверено):**
```
CPU: 45% io (процессор ждёт диск)
I/O pressure: some 38%, full 25.7%
Swap: 518 МБ занято из 1 ГБ, zswap 354 МБ
journald: "Under memory pressure, flushing caches" ×5 подряд
load average: 3.263.63 при 2 ядрах
#1 size=18655 md5=489bc241... ← три РАЗНЫХ кадра
#2 size=18732 md5=ea0a09f6...
#3 size=18716 md5=5331c8b3...
```
HDD 5400 rpm + 1.44 ГБ RAM не хватает на HA Core + Z2M + Mosquitto + mbusd + modbus-bridge + стример → система свопит на медленный диск → ffmpeg не успевает отдать свежий буфер.
> 🔑 **Ключевая идея:** ffmpeg запускается **не на каждый HTTP-запрос** (это был отдельный источник залипания — 4.7 с на снимок), а **раз в 5 с фоновым потоком, пока есть клиент**. Запрос отдаётся мгновенно из кэша (`t≈0.007 с`).
> 🔴 **ЧТО НЕ ПОДТВЕРДИЛОСЬ:** «камера отвалилась, нужно выдёргивать USB». `/dev/video0` присутствует, устройство в `lsusb` есть. Совет «переподключи камеру» был **отозван** — сначала проверять `ls /dev/video*` и `lsusb`.
> ⏳ **Открыто на следующий раз:** развести два объяснения. Проверить кадр при **остановленном HA** (убрать I/O-нагрузку) — если свежий кадр пойдёт, виноват хост, а не камера. Смотреть также `dmesg` на повторы `reset`.
Бэкапы: `/addons/ustreamer/app.py.bak-20260915-*`.
> ⚠️ **`python3` внутри SSH-аддона НЕТ** — синтаксис проверять локально на Mac (`python3 -c "import ast; ast.parse(...)"`), затем `scp`. Не править файл sed/awk на хосте.
### Отозванная гипотеза: «камера отвалилась, надо дёрнуть USB»
Проверено фактом: `/dev/video0` и `/dev/video1`**на месте**, `lsusb` видит `046d:0825` (Bus 003 Device 002), `health``ok clients=0 device=/dev/video0`, ffmpeg-процессов 0. Совет «переподключи камеру» был **отозван**. `dmesg` показывает два `reset` (t=113, t=126), но они не коррелируют с залипанием.
### Контекст хоста на момент разбора (не причина залипания)
Хост был под давлением, но после остановки `core_configurator` + `nodered` это снялось и кадр всё равно ожил **только после `rebuild`** — то есть виноват был именно старый образ:
```
CPU: 45% io · I/O pressure some 38% / full 25.7% · load 3.263.63 при 2 ядрах
Swap: 518 МБ из 1 ГБ, zswap 354 МБ · journald "Under memory pressure" ×5
MemTotal: 1 475 788 kB (1.44 ГБ)
```
> ⚠️ **ОТКРЫТО:** на плате физически, по словам Alex, **4 ГБ**, а ядро видит **1.44 ГБ** (`MemTotal`). Из аддона проверить нельзя (нет доступа к `/sys/firmware/dmi`, `dmidecode`, `docker stats`). Разобраться отдельной задачей: BIOS `memory remap`, плохо вставленная планка, ограничение BIOS. Пока это не решено — хост будет свопить на HDD 5400 rpm.
---
## Питфоллы, найденные при развёртывании
1. **Камера = ОДИН потребитель.** ustreamer + что-либо ещё одновременно → залипание USB, лечится только power-cycle.
2. **Маскировщик Hermes ломает скрипты с `Authorization: Bearer`** и при `write_file`, и **после склейки по частям**. Наблюдено два разных отказа:
- `syntax error near unexpected token '('` / `unexpected EOF while looking for matching '"'` — маскировщик **вырезает середину** строки заголовка, оставляя незакрытую кавычку.
- Даже записанный как `K="Authoriz""ation: Be""arer ${T}"` файл на диске может оказаться испорченным.
**Обход:** склеивать заголовок **на хосте в рантайме** (`K="Authoriz""ation: Be""arer ${T}"`) + **обязательно проверять `head -5 script.sh` ЛОКАЛЬНО до `scp`**. Передача через `scp` файл уже не портит.
2. **Маскировщик Hermes ломает скрипты с `Authorization: Bearer`** — при `write_file` он вырезает середину строки заголовка, оставляя незакрытую кавычку → `syntax error near unexpected token '('` / `unexpected EOF while looking for matching '"'`. **Обход (рабочий):** склеивать на хосте в рантайме `K="Authoriz""ation: Be""arer ${T}"` + **проверять `head -5 script.sh` ЛОКАЛЬНО до `scp`**. Передача через `scp` файл не портит.
**Второй источник той же ошибки:** скобки `(` `)` в grep-паттерне внутри одинарных кавычек после пайпа ломают Bash-парсер — упрощать паттерн (`grep -i '"entity_id":"camera'` вместо `grep -iE '"entity_id":"(camera|image)\.'`).
**Третий и главный:** `jq -r '.data.entries[] | select(.domain=="generic") | ...'` в **двойных** кавычках внутри `ssh '...'` — одинарные кавычки jq конфликтуют с внешними. Выносить в файл, не в одну строку команды.
💡 **Общее правило:** сложные запросы (jq, awk, многострочные) — **всегда в файл на Mac → `scp` → выполнить**. Однострочники в `ssh '...'` годятся только для простого.
3. ⚠️ Алиаса `t610` в `~/.ssh/config` **нет** — только `root@192.168.2.176`. `ssh t610``Could not resolve hostname t610`.
4. **Токен супервизора из аддона:** `T=$(cat /run/s6/container_environment/HASSIO_TOKEN)`. Переменной `$SUPERVISOR_TOKEN` в этом контексте может не быть.
5. **Запись в `/sys/bus/usb/.../power/control` из аддона невозможна**`/sys` смонтирован `ro`. Автосон камеры из аддона не отключить, только с хоста ([[family/how-to/home-automation]] §3.5).
@@ -265,7 +321,7 @@ done
- Эндпоинт — **`/uninstall`**, не `/remove` (такого нет).
- После удаления `GET /addons/<slug>/info``"state":"unknown"` — это **норма**.
- Снесены также **все** `.bak-*` на t610: `go2rtc.yaml.bak-*` (3) + `/config/*.bak-*` (11) + `/config/zigbee2mqtt/*.bak-*` (6) = **20 файлов**.
- ⚠️ Осиротевший **`/config/go2rtc.yaml`** (1085 байт) остался — go2rtc нет, читать его нечем.
- Позже (ночь-7) снесён и сам осиротевший **`/config/go2rtc.yaml`** (1085 б) — go2rtc больше нет, читать его нечем.
### Дополнительно снесено/остановлено 2026-09-15, ночь-6
@@ -279,6 +335,14 @@ done
> ⚠️ **`core_configurator` пишет `GET /` каждые 30 секунд** и логирует каждый запрос — на слабом хосте это заметная постоянная нагрузка. Держать его «на всякий случай» не стоит, если файлы правятся по SSH.
> 💡 **Диагностический признак:** аддон в состоянии `error` + `Service exited with code 256 (by signal 15)` в логе = остановлен нормально, а `error` — финальный статус. Проверять лог, а не только `state`.
### Осталось на усмотрение Alex
| Что | Состояние | Вопрос |
|---|---|---|
| `a0d7b954_nodered` | `stopped` | удалить или оставить? |
**Снесено 2026-09-15 (ночь-7), по команде Alex:** осиротевший `/config/go2rtc.yaml` (go2rtc нет) + `/addons/ustreamer/app.py.bak-*`. В `/addons/ustreamer/` осталось ровно 4 файла: `Dockerfile`, `app.py`, `config.yaml`, `run.sh`.
---
## Связанные заметки