50 Commits
Author SHA1 Message Date
sirmrak 3cc8bb8aef refactor: update web-client files - replace old HTML/JS/CSS with new droidAssist implementation
- Remove old: assist.html, index.html, script.js, styles.css, AI robot.lottie
- Add new: droidAssist.html, droidAssist.css, droidAssist.js, animation.lottie
- Restructure web-client files into html/ directory
2026-05-28 13:19:00 +03:00
sirmrak 8a4686c800 feat: интеграция Lottie анимации AI robot в assist.html
- Заменена CSS сфера на dotlottie-player
- Анимация реагирует на состояния (idle/listening/speaking)
- Скорость анимации зависит от RMS уровня микрофона
- Добавлена обработка ошибок загрузки
- Сохранены все WebSocket команды и события
2026-05-25 18:04:37 +03:00
sirmrak a31fb3a112 feat: интеграция Lottie анимации AI robot в assist.html 2026-05-25 18:01:35 +03:00
sirmrak 26d09501fb fix: graceful reconnect для event service при ConnectionResetError 2026-05-25 17:58:37 +03:00
sirmrakandQwen-Coder 2b4e6f824b feat: WebSocket команды — раздельная громкость mic/snd + запись аудио + обновлённый web-client
- satellite.py: разделён _runtime_volume на _runtime_mic_volume и _runtime_snd_volume
- satellite.py: добавлены команды start_recording/stop_recording (WAV через base64)
- satellite.py: метод _create_wav_from_buffer() для создания WAV из буфера
- web-client: добавлен слайдер громкости микрофона (MIC+/MIC-)
- web-client: добавлены кнопки записи и воспроизведения аудио
- web-client: улучшенный лог событий и отладочные console.log
- docs: websocket-server-plan.md сокращён с 1205 до ~80 строк

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-25 17:58:37 +03:00
sirmrak 61294485ad Анимация 2026-05-25 17:57:04 +03:00
sirmrak 03748505cd fix: фильтрация логов WebSocket — показывать только важные команды
Проблема:
- audio-level спамит 10 раз в секунду
- Не видно других команд (volume, mute, etc.)

Решение:
- Фильтровать audio-level в _broadcast_to_websocket
- Фильтровать audio-level в handle_websocket
- Показывать только важные команды

Результат:
✅ Видны команды: set_volume, mute, unmute, play_sound, etc.
✅ Не видны: audio-level (спам)
✅ Чистые логи для отладки
2026-05-21 20:03:23 +03:00
sirmrak addc15eecc fix: отключить спам websockets.server в логах
Проблема: websockets библиотека логирует каждое сообщение:
DEBUG:websockets.server:> TEXT '{"type": "audio-level"...'

Решение: Установить уровень логирования websockets.server в WARNING
logging.getLogger('websockets.server').setLevel(logging.WARNING)

Результат:
- ✅ Пропадут DEBUG сообщения о каждом WebSocket сообщении
- ✅ Останутся только важные ошибки (WARNING/ERROR)
- ✅ Чистые логи для отладки других функций
2026-05-21 20:01:21 +03:00
sirmrak 9bfbcfd8a8 feat: Web Client — улучшенное подключение к WebSocket
Изменения:
1. Поле для ввода WebSocket адреса (вместо prompt)
   - wsUrlInput с значением по умолчанию
   - Сохраняется между перезагрузками

2. Кнопка CONNECT/DISCONNECT
   - Меняет текст и цвет при подключении
   - DISCONNECT красная кнопка
   - Работают обе кнопки (в шапке и в карточке)

3. Auto-connect
   - Чекбокс 'Auto-connect on load'
   - Автоматическое подключение при загрузке
   - Можно отключить

4. updateConnectButton()
   - Обновляет обе кнопки синхронно
   - 🔌 CONNECT / 🔌 DISCONNECT
2026-05-21 19:56:51 +03:00
sirmrak c17743d829 fix: Web Client — правильное масштабирование RMS
Проблема:
- При RMS=0 столбики 30% (слишком много)
- При RMS=0.3 столбики 2% (слишком мало)

Решение:
- Масштаб: RMS 0 → 10%, RMS 0.15 → 100%
- Формула: avg = 10 + (RMS / 0.15) * 90
- Clamp 10-100
- Симуляция тишины: 10 + random*5 (маленькие колебания)

Теперь:
- Тишина (RMS=0) → 10% высоты
- Говор (RMS=0.1) → ~70% высоты
- Крик (RMS=0.15+) → 100% высоты
2026-05-21 19:47:20 +03:00
sirmrak 6b9c7376b9 fix: Web Client — точная копия оригинальной анимации
Реализована точно такая же анимация как была:
- avg = currentRms * 100 (вместо микрофона)
- Сглаживание rmsSmoothed * 0.7 + avg * 0.3
- Симуляция если RMS < 1 (12 + random * 25)
- Усиление при LISTEN/SPEAK (45 + random * 55)
- 42 столбика с синусом
- Случайные колебания 0.5 + random * 0.3
- Увеличение при SPEAK

Теперь 1-в-1 как оригинал, но с данными satellite!
2026-05-21 19:35:31 +03:00
sirmrak e9b6d7bb06 fix: Web Client — вернул оригинальную красивую анимацию canvas
Изменения:
- 42 столбика как в оригинале
- Синусоидальная анимация
- Градиент cyan → blue
- Сглаживание RMS (0.7 old + 0.3 new)
- Случайные колебания для живости

Теперь красиво как было, но с данными satellite RMS!
2026-05-21 19:31:07 +03:00
sirmrak 4a79874807 feat: Web Client — canvas визуализация от satellite RMS
Изменения:
- Удалён локальный микрофон (initMicVisualizer)
- Canvas рисует RMS историю от satellite
- 64 столбика с градиентом (зелёный → красный)
- Меньше логов (только если RMS > 0.05)

Преимущества:
- ✅ Показывает реальную громкость satellite
- ✅ Нет зависимости от микрофона браузера
- ✅ Меньше спама в логах
2026-05-21 19:26:53 +03:00
sirmrak 6913625830 fix: Web Client — null check для RMS meter
Проблема: rmsFill/rmsValue null до загрузки DOM

Решение:
- Проверка if (!rmsFill || !rmsValue) return
- Инициализация после window.load
- Логирование для отладки
2026-05-21 15:54:31 +03:00
sirmrak 023e283d3f feat: Web Client — RMS meter визуализация
Добавлено:
- RMS bar с градиентом (зелёный → жёлтый → красный)
- Цифровое значение RMS (0.00 - 1.00)
- CSS стили для .rms-bar и .rms-fill
- Размещение под audio canvas

Визуализация:
[████████░░] 0.42
2026-05-21 15:52:04 +03:00
sirmrak fafc39511b fix: Web Client — добавлена функция updateRmsMeter
Проблема: updateRmsMeter is not defined

Решение:
- Добавлена функция updateRmsMeter(level)
- Добавлены DOM элементы rmsFill, rmsValue
- Добавлена переменная rmsTimeout

Теперь audio-level события обрабатываются корректно
2026-05-21 15:47:09 +03:00
sirmrak 40382afac5 fix: _send_audio_level — правильное создание Event
Проблема: Event(...).event() не существует

Решение: Event(type=..., data=...) напрямую
2026-05-21 15:19:06 +03:00
sirmrak 3d9ebfbb0a fix: trigger_event — правильное создание Wyoming Event
Проблема: WyomingEvent(event_type, data).event() не работает

Решение: Использовать Event(type=..., data=...) напрямую
2026-05-21 14:55:27 +03:00
sirmrak f9a3731a98 fix: Web Client — Vol+/- и кнопка RMS
Исправления:
- ✅ Vol+/Vol- теперь обновляют currentVolume (работают многократно)
- ✅ Добавлена кнопка REQUEST RMS для запроса обновления audio-level

Изменения:
- currentVolume = newVol в обработчиках VOL+ и VOL-
- requestRmsBtn отправляет trigger_event для запроса RMS
2026-05-21 14:52:54 +03:00
sirmrak 168ec73b7c fix: Unicode syntax error в start_server.py
Проблема: bytes literal с Unicode символами (🔐, ╔, ║ и т.д.)

Решение:
- HTML response через .encode('utf-8') вместо bytes literal
- ASCII box drawing вместо Unicode символов
- Убраны emoji из print statements

Тест:
  python3 -m py_compile web-client/start_server.py
2026-05-21 14:09:54 +03:00
sirmrak fc816ed7b3 feat: Web UI пароль — HTTP Basic Auth защита
Изменения:
- Отменены WebSocket security настройки (websocket_allowed_ips, websocket_token)
- Добавлена HTTP Basic Auth в start_server.py
- Пароль по умолчанию: admin
- Опция --password для установки своего пароля
- Обновлён README с документацией

Использование:
  python3 start_server.py --port 8080 --password mypassword

Безопасность:
- ✅ WebSocket только локально (127.0.0.1)
- ✅ Web UI защищён паролем
- ✅ Достаточно для локальной сети
2026-05-21 13:50:01 +03:00
sirmrak ed6ecc548b fix: start_server.py — исправлено завершение по Ctrl+C
Проблемы:
- signal_handler блокировал shutdown()
- Порт не освобождался после Ctrl+C
- Невозможно было перезапустить сервер

Решения:
- Убран signal_handler — используется KeyboardInterrupt
- shutdown() вызывается в отдельном потоке (avoid deadlock)
- with контекст для автоматического закрытия
- allow_reuse_address = True для быстрого рестарта
- Обработка OSError при занятом порте
2026-05-21 13:19:59 +03:00
sirmrak 0776d839b9 feat: web-client — синхронизация команд с satellite
Изменения:
- Перенесён start_server.py в web-client/
- Обновлены команды в client.html для соответствия satellite
- handle_websocket_command поддерживает:
  - set_volume (level/volume)
  - mute/unmute
  - play_sound
  - set_led_brightness
  - trigger_event
- Обновлён обработчик входящих событий:
  - voice-started/voice-stopped
  - transcript
  - synthesize
  - audio-start/audio-stop/played
  - timer-started/timer-finished
  - streaming-started/streaming-stopped
  - satellite-connected/satellite-disconnected
  - error
  - audio-level (RMS)

Теперь client.html полностью соответствует API satellite
2026-05-21 13:14:22 +03:00
sirmrak c7bf061611 feat: Web Client разработка — план и документация
Исправления:
- fix: баг volume/level — поддерживаются оба названия параметра
- docs: добавлен раздел Web Client в websocket-server-plan.md
- docs: создан web-client/README.md с полной документацией

Функции Web клиента:
- ✅ set_volume (громкость) — работает
- ✅ mute/unmute — работает
- ⏳ audio-level RMS — требует тестирования
- ⏳ LED управление — требует тестирования
- ⏳ Transcript — требует тестирования

Этапы разработки:
- WC-1: Базовая интеграция ✅
- WC-2: Визуализация (в процессе)
- WC-3: Дополнительные функции
- WC-4: UI/UX
2026-05-21 13:02:35 +03:00
sirmrak 4e5b961f7b fix: исправить зависание при Ctrl+C
- signal_handler вызывает shutdown() который блокируется
- Убран sys.exit(0) из signal handler
- serve_forever() завершается через KeyboardInterrupt
- finally закрывает сокет корректно
2026-05-20 22:59:19 +03:00
sirmrak 9d8e78fe2a fix: исправить баги web сервера
- IndexError в log_message — убран неправильный формат
- Ctrl+C не работал — добавлен signal_handler
- Порт не освобождался — добавлен server_close()
- allow_reuse_address=True — для быстрого рестарта
- Добавлен remote URL в баннер
2026-05-20 22:46:53 +03:00
sirmrak d8ab4736de feat: модернизировать WebSocket клиент с UI управлением
Новые возможности client.html:
- 🔊 Управление громкостью (слайдер 0-100%)
- 🔇/🔊 Кнопки Mute/Unmute
- 🔔 Тест звука (play_sound команда)
- 💡 LED управление (цвет, яркость)
- 📊 RMS Audio Level визуализация (реальное время)
- 🎨 Современный UI (карточки, градиенты, анимации)
- 📱 Адаптивный дизайн

start_web_server.py:
- Простой HTTP сервер для тестирования
- Авто-определение WebSocket URL
- Красивый вывод в консоль

Запуск:
  cd examples/websocket-service
  ./start_web_server.py --port 8080

Открыть: http://localhost:8080/client.html
2026-05-20 22:23:34 +03:00
sirmrak b794f4cafb docs: обновить статус плана — готово к тестированию
- Статус: В разработке → 🔄 Тестирование
- Добавлено: WebSocket события работают (synthesize, audio-start/stop, played)
- Добавлено: TEST сервисы созданы
- Добавлено: Критический баг исправлен (detach_kernel_driver)
- Требуется тестирование: Audio level monitoring, WebSocket команды
2026-05-20 22:13:45 +03:00
sirmrak 27703a2f93 fix: убрать detach_kernel_driver() — это отключает audio интерфейсы
Проблема: detach_kernel_driver(0) отключал ядро от интерфейса 0,
который является частью audio function (интерфейсы 0-2).
После этого audio драйвер не мог восстановить устройство.

Решение: Убрать detach_kernel_driver() вообще.
Control transfer с CTRL_TYPE_VENDOR работает без claim interface.
Так работает xvf_host.py — там тоже нет detach_kernel_driver().

Исправленные файлы:
- wyoming_satellite/xvf_usb.py (уже было исправлено)
- examples/example_led.py
2026-05-20 20:43:01 +03:00
sirmrak b1f7185a4f refactor: вернуть управление яркостью без масштабирования цветов
DoA эффект:
- base_color: 0x303030 (серый, без масштабирования)
- doa_color: 0xD4BC00 (бирюзовый, без масштабирования)
- brightness: управляется отдельно через set_led_brightness()

Это как в оригинале до изменений яркости.
2026-05-20 20:20:36 +03:00
sirmrak 99fa55e15e fix: добавить задержку 500ms после подключения USB устройства
Проблема: устройство может быть не готово к командам сразу после
подключения, что вызывает USB reset.

Решение: time.sleep(0.5) после нахождения устройства.
2026-05-20 20:19:44 +03:00
sirmrak 5a5cac90b1 fix: НЕ делать detach_kernel_driver() — это отключает audio интерфейсы!
Проблема: detach_kernel_driver(0) отключает ядро от интерфейса 0,
который является частью audio function (интерфейсы 0-2).

Решение: Убрать detach_kernel_driver() вообще.
Control transfer с CTRL_TYPE_VENDOR работает без claim interface
и не требует отключения ядра.

Это как в xvf_host.py — там тоже нет detach_kernel_driver().
2026-05-20 20:16:41 +03:00
sirmrak ba006c506b fix: добавить set_led_brightness() для DoA эффекта
Проблема: после перехода на set_led_doa_color() яркость
не устанавливалась отдельно, поэтому LED не работали.

Решение:
- led_controller.set_led_brightness(brightness) после set_led_doa_color()

Восстановлено поведение как в оригинале:
LED_EFFECT [4] → LED_DOA_COLOR → LED_BRIGHTNESS
2026-05-20 20:09:56 +03:00
sirmrak 12d5d14d40 fix: install_test_services.sh — использовать .venv из satellite-dev (не production .venv_satellite)
Изменён порядок поиска venv:
1. ${SCRIPT_DIR}/.venv (satellite-dev)
2. ${SCRIPT_DIR}/.venv_satellite
3. ${HOME_DIR}/projects/.venv_satellite (production)
4. ${HOME_DIR}/.venv_satellite (production)

Теперь test сервисы используют правильное окружение.
2026-05-20 18:11:59 +03:00
sirmrak d9868a0c5b fix: websockets 16.0 API — убрать аргумент 'path' из handle_websocket()
В websockets 16.0 изменилась сигнатура handler:
- Было: async def handler(websocket, path)
- Стало: async def handler(websocket)

Fix для satellite.py:
- handle_websocket(websocket) без path
- Добавлен комментарий про websockets 16.0+
2026-05-20 18:10:33 +03:00
sirmrak b394a2fe74 fix: использовать порт 10702 для test satellite (10701 занят старым wyoming-led)
- services/test/wyoming-satelite-vosk-test.service: порт 10702
- services/test/start_test.sh: порт 10702
- install_test_services.sh: обновлена документация
- docs/deployment/systemd-services.md: обновлена таблица портов

Порты:
- 10700: production satellite
- 10701: старый wyoming-led (production)
- 10702: test satellite (новый)
2026-05-20 18:01:08 +03:00
sirmrak b27d633f06 refactor: упростить test сервисы — только satellite (wake word и event production)
- services/test/*.service — удалены wake word и event test сервисы
- services/test/wyoming-satelite-vosk-test.service — использует production порты
- install_test_services.sh — обновлён для одного сервиса
- services/test/start_test.sh — запуск только satellite
- docs/deployment/systemd-services.md — обновлена документация

Test satellite использует:
- Production wake word (:10400)
- Production event (:10500)
- Те же порты что и production (:10700)
- WebSocket :8675 + Audio Level (новые функции)
2026-05-20 17:50:16 +03:00
sirmrakandQwen-Coder e5914b23b3 feat: добавить TEST сервисы для разработки
- services/test/*.service — 3 test сервиса (порты +1)
- install_test_services.sh — установка test сервисов
- services/test/start_test.sh — быстрый запуск в tmux
- docs/deployment/systemd-services.md — документация

TEST сервисы:
- wyoming-satelite-vosk-test.service :10701 + WebSocket :8675
- wyoming-wakeword-vosk-test.service :10401
- wyoming-event-test.service :10501

Использование:
1. sudo ./install_test_services.sh — установка
2. sudo systemctl start wyoming-*-test.service — запуск
3. ./services/test/start_test.sh — запуск в tmux

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 17:29:36 +03:00
sirmrak 26932097a5 docs: обновить план — Этап WS-6 Audio level выполнен 2026-05-20 17:18:38 +03:00
sirmrak b28052e93e feat: добавить Audio Level monitoring — RMS громкости микрофона в WebSocket
- settings.py: audio_level_enabled, audio_level_interval_ms
- satellite.py: _calculate_rms() — вычисление RMS (0.0-1.0)
- satellite.py: _send_audio_level() — фоновая задача отправки
- satellite.py: _last_mic_level, _audio_level_task атрибуты
- satellite.py: интеграция в _start() / _stop()
- satellite.py: вычисление RMS в event_from_mic() для всех классов
- __main__.py: CLI --audio-level-enabled, --audio-level-interval-ms

Событие в WebSocket (10 раз в секунду):
{type: audio-level, data: {level: 0.42}}
2026-05-20 17:17:18 +03:00
sirmrakandQwen-Coder 5c92eed55b docs: обновить чеклист в плане — этапы WS-1, WS-2, WS-2A, WS-3 выполнены
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 16:57:37 +03:00
sirmrakandQwen-Coder 6398dc6a41 feat: добавить WebSocket сервер для приёма событий и команд
- settings.py: websocket_enabled, websocket_host, websocket_port
- satellite.py: WebSocket сервер + runtime атрибуты (_runtime_volume, _runtime_led_brightness)
- satellite.py: handle_websocket_command() для обработки команд
- satellite.py: _broadcast_to_websocket() для рассылки событий
- satellite.py: интеграция в _start() / _stop()
- __main__.py: CLI аргументы --websocket-enabled/host/port

Поддерживаемые команды:
- set_volume: изменить громкость
- mute/unmute: микрофон
- play_sound: воспроизвести WAV
- set_led_brightness: яркость LED
- trigger_event: отправить событие в HA

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 16:51:27 +03:00
sirmrakandQwen-Coder b1c6ef3385 docs: добавлен протокол WebSocket команд (set_volume, mute, play_sound, etc.)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 16:04:33 +03:00
sirmrakandQwen-Coder 6e06120b55 docs: переименовать план в websocket-server-plan.md
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 15:58:42 +03:00
sirmrakandQwen-Coder 5d59246902 docs: полный пересмотр плана - WebSocket вместо event server
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 15:56:26 +03:00
sirmrakandQwen-Coder f62b0163d0 docs: обновить план multi-event-server с чеклистом выполнения
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 15:33:37 +03:00
sirmrakandQwen-Coder 235fcbece7 feat: добавить setup_dev.sh для быстрой настройки окружения
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 14:59:45 +03:00
sirmrakandQwen-Coder c027567725 refactor: использовать .venv/ в корне проекта для Python окружения
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 14:24:50 +03:00
sirmrakandQwen-Coder 0f66ba1b54 chore: удалить Docker файлы (Dockerfile, docker-compose.yml, .dockerignore, скрипты)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 14:17:43 +03:00
sirmrakandQwen-Coder c259a8c512 chore: удалить лишние файлы из корня проекта (test.wav, start.sh, start-dev.sh, .projectile)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-20 14:12:38 +03:00
31 changed files with 3636 additions and 1150 deletions
-9
View File
@@ -1,9 +0,0 @@
*
!pyproject.toml
!script/setup
!script/run
!wyoming_satellite/*.py
!wyoming_satellite/utils
!wyoming_satellite/VERSION
!docker/run
!sounds/*.wav
-2
View File
@@ -1,2 +0,0 @@
- /.venv/
- /.mypy_cache/
-23
View File
@@ -1,23 +0,0 @@
FROM python:3.11-slim-bookworm
ENV LANG C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install --yes --no-install-recommends avahi-utils
WORKDIR /app
COPY sounds/ ./sounds/
COPY script/setup ./script/
COPY pyproject.toml ./
COPY wyoming_satellite/ ./wyoming_satellite/
RUN script/setup
COPY script/run ./script/
COPY docker/run ./
EXPOSE 10700
ENTRYPOINT ["/app/run"]
-38
View File
@@ -1,38 +0,0 @@
version: "3.8"
services:
microphone:
image: "rhasspy/wyoming-mic-external"
ports:
- "10600:10600"
devices:
- /dev/snd:/dev/snd
group_add:
- audio
command:
- "--device"
- "sysdefault"
- "--debug"
playback:
image: "rhasspy/wyoming-snd-external"
ports:
- "10601:10601"
devices:
- /dev/snd:/dev/snd
group_add:
- audio
command:
- "--device"
- "sysdefault"
- "--debug"
satellite:
image: "rhasspy/wyoming-satellite"
ports:
- "10700:10700"
command:
- "--name"
- "my satellite"
- "--mic-uri"
- "tcp://microphone:10600"
- "--snd-uri"
- "tcp://playback:10601"
- "--debug"
+103
View File
@@ -169,3 +169,106 @@ ExecStart=satellite-python -m wyoming_satellite ...
- [Руководство по развёртыванию](../../DEPLOY.md) - [Руководство по развёртыванию](../../DEPLOY.md)
- [MQTT LED интеграция](integration/mqtt-led.md) - [MQTT LED интеграция](integration/mqtt-led.md)
---
## TEST сервисы (для разработки)
Для тестирования новых функций без влияния на production службы создан **TEST satellite**.
### Отличия от production
| Сервис | Production | Test |
|--------|------------|------|
| Satellite | порт 10700 | порт 10702 + WebSocket :8675 + Audio Level |
| Wake Word | порт 10400 | production сервис |
| Event | порт 10500 | production сервис |
**Важно:** TEST satellite использует production сервисы для wake word и event!
### Вариант 1: Установка TEST сервиса (рекомендуется)
**Установка:**
```bash
cd ~/projects/satellite-dev
sudo ./install_test_services.sh
```
**Запуск:**
```bash
# Убедись что production сервисы запущены
sudo systemctl status wyoming-wakeword-vosk.service
sudo systemctl status wyoming-event.service
# Запуск test satellite
sudo systemctl start wyoming-satelite-vosk-test.service
# Проверка статуса
sudo systemctl status wyoming-satelite-vosk-test.service
# Логи
journalctl -u wyoming-satelite-vosk-test.service -f
```
**Преимущества:**
- ✅ Автоматический перезапуск при ошибке
- ✅ Production службы не затрагиваются
- ✅ Легко переключаться между test/prod
### Вариант 2: Быстрый запуск в tmux
**Требования:** `tmux` установлен (`sudo apt-get install tmux`)
**Запуск:**
```bash
cd ~/projects/satellite-dev
./services/test/start_test.sh
```
**Управление:**
```bash
# Подключиться к сессии
tmux attach -t satellite-test
# Остановить
tmux kill-session -t satellite-test
```
**Важно:** Production сервисы должны быть запущены отдельно!
### Тестирование WebSocket
**Проверка подключения:**
```bash
cd ~/projects/satellite-dev
source .venv/bin/activate
# Python тест клиент
python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675
# Или браузер
# Откройте examples/websocket-service/client.html
```
**Проверка audio level:**
```javascript
// В browser console (client.html)
// Уровень микрофона должен обновляться 10 раз в секунду
// {"type": "audio-level", "data": {"level": 0.42}}
```
### Остановка TEST сервисов
```bash
# systemd сервис
sudo systemctl stop wyoming-satelite-vosk-test.service
# tmux сессия
tmux kill-session -t satellite-test
```
-918
View File
@@ -1,918 +0,0 @@
# План: Множественные подключения event сервисов к Wyoming Satellite
**Дата:** 2026-05-20
**Статус:** Готов к реализации
**Ветка:** `feature/multi-event-server`
**Директория для разработки:** `~/projects/satellite-dev`
---
## Стратегия разработки
### Изоляция разработки
**Текущая версия (production):**
- Директория: `~/projects/Satellite`
- Ветка: `master`
- Статус: ✅ Рабочая версия, используется в production
**Версия для разработки:**
- Директория: `~/projects/satellite-dev`
- Ветка: `feature/multi-event-server`
- Статус: 🚧 Экспериментальная версия
### Преимущества такого подхода
- ✅ **Полная изоляция** — эксперименты не влияют на рабочую систему
- ✅ **Быстрый откат** — просто переключаемся на `~/projects/Satellite`
- ✅ **Чистая история** — merge в master только после тестирования
- ✅ **Параллельная работа** — можно продолжать фиксить баги в Satellite пока идёт разработка
---
## Архитектура
### Текущее состояние
```
┌─────────────────┐ ┌─────────────────┐
│ Home Assistant │ ──────► │ Satellite │
│ (Wyoming │ 10700 │ (Wyoming │
│ Client) │ │ Server) │
└─────────────────┘ └────────┬────────┘
│
│ 10500 (один клиент!)
▼
┌─────────────────┐
│ Event Service │
│ (клиент) │
└─────────────────┘
```
**Проблема:** Satellite поддерживает **только одного** event клиента через `event.uri`
### Целевое состояние
```
┌─────────────────┐ ┌─────────────────┐
│ Home Assistant │ ──────► │ Satellite │
│ (Wyoming │ 10700 │ (Wyoming │
│ Client) │ │ Server) │
└─────────────────┘ └────────┬────────┘
│
┌───────────────┼───────────────┐
│ │ │
│ 10600 (event │ │
│ server) │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ LED │ │ MQTT │ │ Logger │
│ Service │ │ Service │ │ Service │
└─────────┘ └──────────┘ └──────────┘
```
**Решение:** Event сервер на satellite для множественных подключений
---
## Этапы реализации
### Этап 1: Подготовка (30 мин)
#### 1.1 Проверка окружения
```bash
# Production версия (не трогать!)
cd ~/projects/Satellite
git status # Должен быть clean
# Development версия
cd ~/projects/satellite-dev
git status # Должен быть на feature/multi-event-server
```
#### 1.2 Настройка venv для satellite-dev
```bash
# Создание отдельного venv
cd ~/projects/satellite-dev
python3 -m venv ~/.venv_satellite_dev
source ~/.venv_satellite_dev/bin/activate
# Установка зависимостей
pip install -r requirements.txt
# Установка пакета в editable режиме
pip install -e .
```
#### 1.3 Проверка что всё работает
```bash
cd ~/projects/satellite-dev
source ~/.venv_satellite_dev/bin/activate
# Проверка импорта
python3 -c "from wyoming_satellite.satellite import SatelliteBase; print('OK')"
# Проверка что тестовый клиент работает
python3 examples/test_event_client.py --help # После создания
```
```python
#!/usr/bin/env python3
"""Test event client для подключения к satellite."""
import argparse
import asyncio
import logging
from wyoming.event import Event
from wyoming.client import AsyncClient
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("test_event_client")
async def main(args):
logger.info(f"Connecting to {args.uri}...")
client = AsyncClient.from_uri(args.uri)
await client.connect()
logger.info("Connected!")
while True:
event = await client.read_event()
if event is None:
logger.warning("Server disconnected")
break
logger.info(f"Event: {event}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--uri", default="tcp://127.0.0.1:10600")
args = parser.parse_args()
asyncio.run(main(args))
```
---
### Этап 2: Модификация `wyoming_satellite/settings.py` (1 час)
**Добавить настройки event сервера:**
```python
@dataclass(frozen=True)
class EventSettings(ServiceSettings):
"""External event service settings."""
startup: Optional[List[str]] = None
streaming_start: Optional[List[str]] = None
streaming_stop: Optional[List[str]] = None
detect: Optional[List[str]] = None
detection: Optional[List[str]] = None
played: Optional[List[str]] = None
transcript: Optional[List[str]] = None
stt_start: Optional[List[str]] = None
stt_stop: Optional[List[str]] = None
synthesize: Optional[List[str]] = None
tts_start: Optional[List[str]] = None
tts_stop: Optional[List[str]] = None
error: Optional[List[str]] = None
connected: Optional[List[str]] = None
disconnected: Optional[List[str]] = None
# === НОВЫЕ ПОЛЯ: Event server ===
server_enabled: bool = False
"""True если event сервер должен быть запущен."""
server_host: str = "0.0.0.0"
"""Host для event сервера."""
server_port: int = 10600
"""Port для event сервера."""
```
**Итого изменений:** ~20 строк
---
### Этап 3: Модификация `wyoming_satellite/satellite.py` (3-4 часа)
#### 3.1 Добавить атрибуты для event сервера
В `SatelliteBase.__init__()` после существующих атрибутов:
```python
# Event server (новый)
self._event_server: Optional[asyncio.Server] = None
self._event_clients: List[asyncio.StreamWriter] = []
self._event_clients_lock = asyncio.Lock()
```
**Импорт:** `List` из `typing` (если ещё не импортирован)
#### 3.2 Метод для рассылки событий всем клиентам
После существующих методов добавить:
```python
async def _broadcast_to_clients(self, event: Event) -> None:
"""
Отправить событие всем подключённым event клиентам.
Мёртвые подключения автоматически удаляются.
"""
async with self._event_clients_lock:
if not self._event_clients:
return
dead_clients = []
for i, writer in enumerate(self._event_clients):
try:
await async_write_event(event, writer)
except Exception:
_LOGGER.debug(
f"Failed to send event to client {i}, removing"
)
dead_clients.append(i)
# Удалить мёртвые подключения
for i in reversed(dead_clients):
self._event_clients.pop(i)
```
#### 3.3 Заменить `forward_event()`
**Было:**
```python
async def forward_event(self, event: Event) -> None:
"""Forward an event to the event service."""
if self._event_queue is not None:
self._event_queue.put_nowait(event)
```
**Стало:**
```python
async def forward_event(self, event: Event) -> None:
"""
Forward an event to all event consumers.
Sends to:
- Legacy event service (if configured via event.uri)
- All connected event server clients
"""
# Legacy event service (через TCP клиент)
if self._event_queue is not None:
self._event_queue.put_nowait(event)
# Event server клиенты
await self._broadcast_to_clients(event)
```
#### 3.4 Event сервер для подключений
Добавить методы после `_event_task_proc()`:
```python
async def start_event_server(self) -> None:
"""
Запустить сервер для event клиентов.
Клиенты могут подключаться к порту server_port и получать все события.
"""
async def handle_client(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter
) -> None:
client_addr = writer.get_extra_info('peername')
_LOGGER.info(f"Event client connected: {client_addr}")
async with self._event_clients_lock:
self._event_clients.append(writer)
try:
# Ждать отключения клиента
await reader.read(1)
except asyncio.IncompleteReadError:
pass # Нормальное отключение
except Exception as exc:
_LOGGER.debug(f"Event client error: {exc}")
finally:
async with self._event_clients_lock:
if writer in self._event_clients:
self._event_clients.remove(writer)
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
_LOGGER.info(f"Event client disconnected: {client_addr}")
self._event_server = await asyncio.start_server(
handle_client,
self.settings.event.server_host,
self.settings.event.server_port
)
_LOGGER.info(
f"Event server listening on "
f"{self.settings.event.server_host}:{self.settings.event.server_port}"
)
async def stop_event_server(self) -> None:
"""
Остановить event сервер и закрыть все подключения.
"""
_LOGGER.info("Stopping event server...")
if self._event_server:
self._event_server.close()
await self._event_server.wait_closed()
self._event_server = None
# Закрыть все подключения
async with self._event_clients_lock:
for writer in self._event_clients:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
self._event_clients.clear()
_LOGGER.info("Event server stopped")
```
#### 3.5 Интеграция в жизненный цикл
**В `_start()`:**
```python
async def _start(self) -> None:
"""Connect to services."""
self.state = State.STARTING
await self._connect_to_services()
# Запустить event сервер (новый)
if self.settings.event.server_enabled:
await self.start_event_server()
self.state = State.STARTED
await self.started()
```
**В `_stop()`:**
```python
async def _stop(self) -> None:
"""Disconnect from services."""
self.server_id = None
self._writer = None
# Остановить event сервер (новый)
if self._event_server:
await self.stop_event_server()
await self._disconnect_from_services()
self._disable_ping()
self.state = State.STOPPED
```
**Итого изменений:** ~200 строк
---
### Этап 4: Модификация `wyoming_satellite/__main__.py` (30 мин)
#### 4.1 Добавить CLI аргументы
После существующих аргументов (перед `args = parser.parse_args()`):
```python
# Event server
parser.add_argument(
"--event-server-enabled",
action="store_true",
help="Enable event server for multiple event clients (default: disabled)"
)
parser.add_argument(
"--event-server-host",
default="0.0.0.0",
help="Host for event server (default: 0.0.0.0)"
)
parser.add_argument(
"--event-server-port",
type=int,
default=10600,
help="Port for event server (default: 10600)"
)
```
#### 4.2 Передать настройки в SatelliteSettings
В существующем `EventSettings(...)`:
```python
event=EventSettings(
uri=args.event_uri,
command=split_command(args.event_command),
startup=split_command(args.startup_command),
streaming_start=split_command(args.streaming_start_command),
streaming_stop=split_command(args.streaming_stop_command),
detect=split_command(args.detect_command),
detection=split_command(args.detection_command),
played=split_command(args.tts_played_command),
transcript=split_command(args.transcript_command),
stt_start=split_command(args.stt_start_command),
stt_stop=split_command(args.stt_stop_command),
synthesize=split_command(args.synthesize_command),
tts_start=split_command(args.tts_start_command),
tts_stop=split_command(args.tts_stop_command),
error=split_command(args.error_command),
connected=split_command(args.connected_command),
disconnected=split_command(args.disconnected_command),
# === НОВЫЕ ПОЛЯ ===
server_enabled=args.event_server_enabled,
server_host=args.event_server_host,
server_port=args.event_server_port,
),
```
**Итого изменений:** ~30 строк
---
### Этап 5: Тестовый event клиент (1 час)
#### 5.1 Создать `examples/test_event_client.py`
```python
#!/usr/bin/env python3
"""
Test event client для подключения к Wyoming Satellite.
Подключается к event server и получает все события.
Используется для тестирования и отладки.
Пример использования:
python3 test_event_client.py --uri tcp://127.0.0.1:10600
"""
import argparse
import asyncio
import logging
import signal
import sys
from datetime import datetime
from wyoming.event import Event
from wyoming.client import AsyncClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("test_event_client")
stop_event = asyncio.Event()
def signal_handler(sig, frame):
logger.info(f"Получен сигнал {sig}, завершаю...")
stop_event.set()
async def main(args):
logger.info(f"Connecting to {args.uri}...")
client = AsyncClient.from_uri(args.uri)
try:
await client.connect()
logger.info("✅ Connected!")
event_count = 0
while not stop_event.is_set():
try:
event = await asyncio.wait_for(
client.read_event(),
timeout=5.0
)
if event is None:
logger.warning("⚠️ Server disconnected")
break
event_count += 1
event_type = event.get("type", "unknown")
logger.info(f"📨 [{event_count}] {event_type}")
if args.verbose:
logger.debug(f" Data: {event}")
except asyncio.TimeoutError:
# Проверка stop_event
continue
except Exception as exc:
logger.error(f"❌ Error reading event: {exc}")
break
logger.info(f"Всего получено событий: {event_count}")
except Exception as exc:
logger.error(f"❌ Error: {exc}")
finally:
try:
await client.disconnect()
except Exception:
pass
logger.info("👋 Disconnected")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Test event client для Wyoming Satellite"
)
parser.add_argument(
"--uri",
default="tcp://127.0.0.1:10600",
help="URI для подключения (default: tcp://127.0.0.1:10600)"
)
parser.add_argument(
"--verbose",
action="store_true",
help="Выводить полные данные событий"
)
args = parser.parse_args()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
asyncio.run(main(args))
except KeyboardInterrupt:
pass
```
#### 5.2 Сделать исполняемым
```bash
chmod +x examples/test_event_client.py
```
---
### Этап 6: Тестирование (2-3 часа)
#### 6.1 Запустить satellite с event сервером
```bash
cd ~/projects/Satellite
source ~/.venv_satellite/bin/activate
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600 \
--debug
```
**Ожидаемый вывод:**
```
INFO: Ready
INFO: Event server listening on 0.0.0.0:10600
INFO: Слушаем tcp://0.0.0.0:10700
```
#### 6.2 Проверить подключения
**Терминал 1: Home Assistant (порт 10700)**
```bash
# Эмуляция подключения HA (или реальное HA)
# HA должен подключиться автоматически через Zeroconf
```
**Терминал 2: test_event_client #1 (порт 10600)**
```bash
source ~/.venv_satellite/bin/activate
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
**Терминал 3: test_event_client #2 (порт 10600)**
```bash
source ~/.venv_satellite/bin/activate
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
**Терминал 4:原有 wyoming-led.py (порт 10500)**
```bash
source ~/.venv_satellite/bin/activate
cd examples
python3 wyoming-led.py --host 127.0.0.1 --port 10500
```
#### 6.3 Проверить логи
**В логе satellite:**
```
INFO: Event client connected: ('127.0.0.1', XXXX) # клиент 1
INFO: Event client connected: ('127.0.0.1', XXXX) # клиент 2
INFO: Connected to server # Home Assistant
```
**Проверить:**
- ✅ Все 3 клиента получают события
- ✅ Home Assistant продолжает работать (порт 10700)
- ✅ При отключении клиента satellite не падает
- ✅ wyoming-led.py работает через старый порт 10500
#### 6.4 Тест на устойчивость
```bash
# Отключить клиента 1 (Ctrl+C)
# Проверить что клиент 2 продолжает работать
# Отключить wyoming-led.py
# Проверить что event клиенты работают
# Отключить Home Assistant
# Проверить что event клиенты работают
```
---
### Этап 7: Обновление документации (30 мин)
#### 7.1 `docs/integration/mqtt-led.md`
Добавить секцию после "Архитектура USB контроллера":
```markdown
## Event Server (множественные подключения)
Начиная с версии 2026.06, Wyoming Satellite поддерживает event server для
множественных подключений.
### Преимущества
- ✅ **Масштабируемость:** Неограниченное количество event клиентов
- ✅ **Надёжность:** Падение клиента не влияет на satellite
- ✅ **Гибкость:** Каждый клиент выбирает какие события обрабатывать
### Запуск
```bash
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600
```
### Подключение клиента
```python
from wyoming.client import AsyncClient
client = AsyncClient.from_uri("tcp://127.0.0.1:10600")
await client.connect()
while True:
event = await client.read_event()
# Обработка события
```
### Обратная совместимость
Старый `wyoming-led.py` продолжает работать через `--event-uri`:
```bash
python3 -m wyoming_satellite \
--event-uri tcp://127.0.0.1:10500 \
--event-server-enabled \
--event-server-port 10600
```
Оба метода работают параллельно!
```
#### 7.2 `docs/deployment/systemd-services.md`
Обновить секцию `wyoming-event.service`:
```markdown
### Event Server (новый)
Для поддержки множественных event клиентов добавьте в сервис:
```ini
ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python3 \
/home/sirmrak/projects/Satellite/wyoming_satellite/__main__.py \
--uri tcp://0.0.0.0:10700 \
--event-server-enabled \
--event-server-port 10600
```
Клиенты подключаются к порту 10600:
```bash
python3 examples/test_event_client.py --uri tcp://127.0.0.1:10600
```
```
#### 7.3 `DEPLOY.md`
Добавить секцию после "LED управление":
```markdown
## Event Server (множественные подключения)
Для подключения нескольких event сервисов одновременно:
```bash
python3 -m wyoming_satellite \
--event-server-enabled \
--event-server-port 10600
```
**Примеры использования:**
- LED управление + MQTT уведомления + логирование в базу
- Несколько независимых обработчиков событий
- Тестовые клиенты для отладки
**Документация:** [Event Server](docs/integration/mqtt-led.md#event-server)
```
---
### Этап 8: Merge в master (после успешного тестирования)
```bash
# Проверка что всё работает
git status
git diff master
# Merge
git checkout master
git pull origin master
git merge feature/multi-event-server
# Push
git push origin master
# Удалить ветку (опционально)
git branch -d feature/multi-event-server
```
---
## Итоговые изменения
| Файл | Тип | Изменения | Строк |
|------|-----|-----------|-------|
| `wyoming_satellite/settings.py` | Изменён | EventSettings + server настройки | ~20 |
| `wyoming_satellite/satellite.py` | Изменён | Event server + broadcast | ~200 |
| `wyoming_satellite/__main__.py` | Изменён | CLI аргументы | ~30 |
| `examples/test_event_client.py` | Новый | Тестовый клиент | ~80 |
| `docs/integration/mqtt-led.md` | Изменён | Документация event server | ~40 |
| `docs/deployment/systemd-services.md` | Изменён | Обновление сервисов | ~20 |
| `DEPLOY.md` | Изменён | Quick start | ~15 |
| **ВСЕГО** | | | **~405 строк** |
---
## Риски и план Б
| Риск | Вероятность | Влияние | Решение |
|------|-------------|---------|---------|
| Конфликт портов (10600 занят) | Низкая | Среднее | Проверка `lsof -i :10600`, смена порта |
| Утечка памяти (клиенты) | Средняя | Высокое | Логирование подключений, таймауты |
| Падение satellite | Низкая | Критичное | Тестирование в ветке, backup файлов |
| Home Assistant отключится | Низкая | Критичное | Порт 10700 не меняется, обратная совместимость |
| Event клиенты не получают события | Средняя | Среднее | Проверка `_broadcast_to_clients()`, логирование |
### План Б: Откат
Если что-то идёт не так:
```bash
cd ~/projects/Satellite
git checkout master
# Восстановить файлы
cp wyoming_satellite/satellite.py.backup wyoming_satellite/satellite.py
cp wyoming_satellite/settings.py.backup wyoming_satellite/settings.py
cp wyoming_satellite/__main__.py.backup wyoming_satellite/__main__.py
# Перезапустить сервисы
sudo systemctl daemon-reload
sudo systemctl restart wyoming-satelite-vosk.service
```
---
## Контрольный список
### Перед началом
- [ ] Создана ветка `feature/multi-event-server`
- [ ] Созданы backup файлов
- [ ] Текущая версия работает стабильно
### После этапа 3
- [ ] satellite.py изменён без синтаксических ошибок
- [ ] `python3 -m py_compile wyoming_satellite/satellite.py` проходит
- [ ] Импорт `from wyoming_satellite.satellite import SatelliteBase` работает
### После этапа 4
- [ ] `python3 -m wyoming_satellite --help` показывает новые аргументы
- [ ] `--event-server-enabled` распознаётся
### После этапа 5
- [ ] `examples/test_event_client.py --help` работает
- [ ] Клиент подключается к порту 10600
### После этапа 6
- [ ] Satellite запускается с `--event-server-enabled`
- [ ] Multiple clients can connect
- [ ] Все клиенты получают события
- [ ] Home Assistant работает (порт 10700)
- [ ] wyoming-led.py работает (порт 10500)
- [ ] При отключении клиента satellite не падает
### После этапа 7
- [ ] Документация обновлена
- [ ] Примеры в документации работают
### Перед merge
- [ ] Все тесты пройдены
- [ ] Документация актуальна
- [ ] Backup файлы удалены (опционально)
---
## Приложения
### A. Пример события в event клиенте
```json
{
"type": "voice-started",
"data": {}
}
```
```json
{
"type": "voice-stopped",
"data": {}
}
```
```json
{
"type": "transcript",
"data": {
"text": "привет дроид"
}
}
```
### B. Диагностика
```bash
# Проверка портов
lsof -i :10600
lsof -i :10700
lsof -i :10500
# Проверка подключений
netstat -tlnp | grep python
# Логи satellite
journalctl -u wyoming-satelite-vosk.service -f
# Тест подключения
telnet 127.0.0.1 10600
```
### C. Сравнение подходов
| Аспект | Старый (push) | Новый (pull) |
|--------|---------------|--------------|
| Подключения | 1 satellite → 1 event | N clients → 1 satellite |
| Отказоустойчивость | Падение event → satellite работает | Падение client → satellite работает |
| Масштабируемость | Ограничено 1 сервисом | Неограниченно клиентов |
| Сложность | Проще | Сложнее (управление клиентами) |
| Гибкость | Низкая | Высокая (каждый клиент выбирает события) |
| Обратная совместимость | ✅ Работает | ✅ Работает через event.uri |
---
**Конец документа**
+79
View File
@@ -0,0 +1,79 @@
# План: WebSocket сервер для Wyoming Satellite
**Дата:** 2026-05-20
**Статус:** 🔄 Тестирование
**Ветка:** `satellite-dev`
---
## Что сделано
| Этап | Что | Статус |
|------|-----|--------|
| WS-1 | `settings.py` — поля WebSocket в EventSettings | ✅ |
| WS-2 | `satellite.py` — сервер, broadcast, команды | ✅ |
| WS-2A | Протокол команд (7 команд) | ✅ |
| WS-3 | `__main__.py` — CLI аргументы | ✅ |
| WS-4 | Audio level (RMS) мониторинг | ✅ |
| WS-4 | Audio recording | ✅ |
## Что осталось
| Этап | Что | Статус |
|------|-----|--------|
| WS-5 | Обновить `docs/integration/mqtt-led.md` | ⏳ |
| — | Merge в master | ⏳ |
## Чеклист тестирования (WS-4)
- [ ] Satellite запускается с `--websocket-enabled`
- [ ] Браузер подключается к порту 8675
- [ ] События приходят (`synthesize`, `audio-start/stop`, `played`)
- [x] `audio-level` приходит ~10 раз/сек
- [x] Команды работают: `set_volume`, `set_mic_volume`, `mute/unmute`
- [ ] Команды работают: `play_sound`, `set_led_brightness`
- [x] `start_recording` / `stop_recording` — возвращают WAV
- [ ] Home Assistant работает (порт 10700) — не сломалось
- [ ] wyoming-led.py работает (порт 10500) — не сломалось
- [ ] При отключении клиента satellite не падает
---
## Архитектура (справка)
```
Home Assistant ──10700──► Satellite
├── Event CLIENT :10500 → LED, MQTT
└── WebSocket SERVER :8675 ← Browser
```
## Протокол команд (справка)
| Команда | Данные | Описание |
|---------|--------|----------|
| `set_volume` | `{"level": 0.0-1.0}` | Громкость TTS |
| `set_mic_volume` | `{"level": 0.0-2.0}` | Чувствительность микрофона |
| `mute` / `unmute` | `{}` | Заглушить/включить микрофон |
| `play_sound` | `{"path": "/path.wav"}` | Воспроизвести WAV |
| `set_led_brightness` | `{"brightness": 0-255}` | Яркость LED |
| `start_recording` | `{}` | Начать запись с микрофона |
| `stop_recording` | `{}` | Остановить, вернуть WAV (base64) |
| `trigger_event` | `{"event_type": "...", "data": {...}}` | Отправить событие в HA |
## Запуск
```bash
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--event-uri tcp://127.0.0.1:10500 \
--websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled
```
## Известные проблемы
- **ALSA card hardcoded** — `-c 2` в `WakeStreamingSatellite` (см. `satellite.py` ~1700)
- **`_kill_aplay_processes()`** — может убить сторонние aplay процессы
- **Recording buffer не ограничен** — длительная запись ест память
- **`trigger_event`** — создаёт сырой `Event`, без валидации Wyoming
+4 -6
View File
@@ -137,12 +137,10 @@ class XVF3800LED:
_LOGGER.debug("Looking for PID %04x...", pid) _LOGGER.debug("Looking for PID %04x...", pid)
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=pid) dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=pid)
if dev is not None: if dev is not None:
try: # НЕ делаем detach_kernel_driver() — это отключает audio интерфейсы!
if dev.is_kernel_driver_active(0): # Control transfer с CTRL_TYPE_VENDOR работает без claim interface
_LOGGER.info("Detaching kernel driver from device...") # Так работает xvf_host.py — там тоже нет detach_kernel_driver()
dev.detach_kernel_driver(0)
except Exception as exc:
_LOGGER.warning("Could not detach kernel driver: %s", exc)
self._dev = dev self._dev = dev
_LOGGER.info( _LOGGER.info(
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)", "✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
+554 -82
View File
@@ -1,103 +1,491 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wyoming Satellite WebSocket</title> <title>Wyoming Satellite Control</title>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: 'Courier New', monospace; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
padding: 20px; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
background: #1a1a2e;
color: #eee; color: #eee;
margin: 0; min-height: 100vh;
padding: 20px;
} }
h1 { color: #00d9ff; margin-bottom: 10px; }
#status { .container {
padding: 8px 12px; max-width: 1200px;
border-radius: 4px; margin: 0 auto;
display: inline-block;
margin-bottom: 15px;
font-weight: bold;
} }
#status.connected { background: #2d5a3d; color: #7fff7f; }
#status.disconnected { background: #5a2d2d; color: #ff7f7f; } /* Header */
#status.connecting { background: #5a5a2d; color: #ffff7f; } .header {
#log { display: flex;
background: #0f0f1a; justify-content: space-between;
border: 1px solid #333; align-items: center;
border-radius: 4px; margin-bottom: 20px;
padding: 15px; padding: 20px;
height: calc(100vh - 180px); background: rgba(255, 255, 255, 0.05);
overflow-y: auto; border-radius: 12px;
white-space: pre-wrap; backdrop-filter: blur(10px);
word-break: break-all;
} }
.event {
border-left: 3px solid #00d9ff; .header h1 {
padding-left: 10px; font-size: 24px;
margin: 10px 0; background: linear-gradient(90deg, #00d9ff, #00ff88);
background: rgba(0, 217, 255, 0.05); -webkit-background-clip: text;
padding: 10px; -webkit-text-fill-color: transparent;
border-radius: 0 4px 4px 0; margin-bottom: 5px;
} }
.event-type { color: #00d9ff; font-weight: bold; }
.event-data { color: #aaa; } .status {
.clear-btn {
background: #00d9ff;
color: #1a1a2e;
border: none;
padding: 8px 16px; padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.status::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status.connected { background: rgba(0, 255, 136, 0.2); color: #00ff88; }
.status.connected::before { background: #00ff88; }
.status.disconnected { background: rgba(255, 71, 87, 0.2); color: #ff4757; }
.status.disconnected::before { background: #ff4757; animation: none; }
.status.connecting { background: rgba(255, 193, 7, 0.2); color: #ffc107; }
.status.connecting::before { background: #ffc107; }
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
/* Cards */
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card h2 {
font-size: 18px;
margin-bottom: 20px;
color: #00d9ff;
display: flex;
align-items: center;
gap: 10px;
}
/* Controls */
.control-group {
margin-bottom: 20px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #aaa;
}
/* Volume Slider */
.volume-slider {
width: 100%;
height: 8px;
border-radius: 4px; border-radius: 4px;
background: linear-gradient(90deg, #00d9ff 0%, #00ff88 100%);
outline: none;
-webkit-appearance: none;
cursor: pointer; cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.volume-value {
text-align: center;
font-size: 24px;
font-weight: bold; font-weight: bold;
color: #00d9ff;
margin-top: 10px;
}
/* Buttons */
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: linear-gradient(90deg, #00d9ff, #00ff88);
color: #1a1a2e;
}
.btn-danger {
background: linear-gradient(90deg, #ff4757, #ff6b81);
color: white;
}
.btn-success {
background: linear-gradient(90deg, #00ff88, #00d9ff);
color: #1a1a2e;
}
.btn-warning {
background: linear-gradient(90deg, #ffc107, #ff9800);
color: #1a1a2e;
}
.btn-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* RMS Meter */
.rms-meter {
margin-bottom: 20px;
}
.rms-bar {
height: 30px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.rms-fill {
height: 100%;
background: linear-gradient(90deg, #00ff88 0%, #ffc107 70%, #ff4757 100%);
transition: width 0.1s ease;
border-radius: 4px;
}
.rms-value {
text-align: center;
font-size: 32px;
font-weight: bold;
margin-top: 10px;
font-family: 'Courier New', monospace;
}
.rms-label {
text-align: center;
color: #aaa;
font-size: 12px;
margin-top: 5px;
}
/* Event Log */
.log-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 20px;
height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.log-entry {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 3px solid #00d9ff;
background: rgba(0, 217, 255, 0.05);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.log-entry.audio-level {
border-left-color: #00ff88;
background: rgba(0, 255, 136, 0.05);
}
.log-entry.error {
border-left-color: #ff4757;
background: rgba(255, 71, 87, 0.05);
}
.log-entry.system {
border-left-color: #ffc107;
background: rgba(255, 193, 7, 0.05);
}
.log-time {
color: #666;
margin-right: 10px;
}
.log-type {
color: #00d9ff;
font-weight: bold;
}
.log-data {
color: #aaa;
margin-left: 10px; margin-left: 10px;
} }
.clear-btn:hover { background: #00b8d9; }
.header { display: flex; align-items: center; justify-content: space-between; } /* Settings */
.settings-panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
display: none;
}
.settings-panel.show {
display: block;
}
.settings-panel input[type="text"] {
width: 100%;
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #eee;
font-size: 14px;
margin-bottom: 10px;
}
.settings-panel input[type="text"]:focus {
outline: none;
border-color: #00d9ff;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
color: #aaa;
cursor: pointer;
}
.checkbox-label input {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.grid {
grid-template-columns: 1fr;
}
.btn-group {
justify-content: center;
}
}
</style> </style>
</head> </head>
<body> <body>
<div class="container">
<!-- Header -->
<div class="header"> <div class="header">
<div> <div>
<h1>🛰️ Wyoming Satellite WebSocket</h1> <h1>🛰️ Wyoming Satellite Control</h1>
<div id="status" class="connecting">Connecting...</div> <div style="color: #aaa; font-size: 14px;">WebSocket Client & Audio Level Monitor</div>
</div> </div>
<div> <div class="status" id="status">
<button class="clear-btn" onclick="toggleSettings()">⚙️ Settings</button> Connecting...
<button class="clear-btn" onclick="clearLog()">Clear Log</button>
</div> </div>
</div> </div>
<div id="settings" style="display:none; background:#0f0f1a; padding:15px; border-radius:4px; margin-bottom:15px;"> <!-- Settings -->
<label for="wsUrl" style="color:#00d9ff; font-weight:bold;">WebSocket URL:</label> <div class="settings-panel" id="settingsPanel">
<input type="text" id="wsUrl" value="ws://localhost:8675" <label style="color: #00d9ff; font-weight: 600; display: block; margin-bottom: 8px;">
style="width:100%; padding:8px; margin:5px 0; background:#1a1a2e; color:#eee; border:1px solid #333; border-radius:4px;"> WebSocket URL:
<button class="clear-btn" onclick="connect()">Connect</button> </label>
<label style="margin-left:10px; color:#aaa;"> <input type="text" id="wsUrl" value="ws://localhost:8675">
<input type="checkbox" id="autoReconnect" checked> Auto-reconnect <div style="display: flex; gap: 10px; align-items: center; margin-top: 10px;">
<button class="btn btn-primary" onclick="connect()">
🔌 Connect
</button>
<label class="checkbox-label">
<input type="checkbox" id="autoReconnect" checked>
Auto-reconnect
</label> </label>
</div> </div>
</div>
<div id="log"></div> <!-- Control Grid -->
<div class="grid">
<!-- Volume Control -->
<div class="card">
<h2>🔊 Volume Control</h2>
<div class="control-group">
<label>Master Volume</label>
<input type="range" class="volume-slider" id="volumeSlider"
min="0" max="100" value="100" oninput="updateVolume(this.value)">
<div class="volume-value" id="volumeValue">100%</div>
</div>
<div class="btn-group">
<button class="btn btn-danger" onclick="sendCommand('mute', {})">
🔇 Mute
</button>
<button class="btn btn-success" onclick="sendCommand('unmute', {})">
🔊 Unmute
</button>
</div>
</div>
<!-- RMS Audio Level -->
<div class="card">
<h2>📊 RMS Audio Level</h2>
<div class="rms-meter">
<div class="rms-bar">
<div class="rms-fill" id="rmsFill" style="width: 0%;"></div>
</div>
<div class="rms-value" id="rmsValue">0.00</div>
<div class="rms-label">Real-time microphone level</div>
</div>
<div class="btn-group">
<button class="btn btn-warning" onclick="sendCommand('play_sound', {path: '/home/sirmrak/projects/satellite-dev/sounds/awake.wav'})">
🔔 Test Sound
</button>
</div>
</div>
<!-- LED Control -->
<div class="card">
<h2>💡 LED Control</h2>
<div class="control-group">
<label>Brightness</label>
<input type="range" class="volume-slider" id="ledBrightness"
min="0" max="255" value="128" oninput="updateLedBrightness(this.value)">
<div class="volume-value" id="ledBrightnessValue">128</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="sendCommand('set_led_color', {r: 0, g: 188, b: 212})">
🔵 Cyan
</button>
<button class="btn btn-danger" onclick="sendCommand('set_led_color', {r: 255, g: 0, b: 0})">
🔴 Red
</button>
<button class="btn btn-success" onclick="sendCommand('set_led_color', {r: 0, g: 255, b: 0})">
🟢 Green
</button>
</div>
</div>
</div>
<!-- Event Log -->
<div class="card">
<h2>📋 Event Log</h2>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<button class="btn btn-primary" onclick="clearLog()">
🗑️ Clear Log
</button>
<button class="btn btn-primary" onclick="toggleSettings()">
⚙️ Settings
</button>
<label class="checkbox-label" style="margin-left: auto;">
<input type="checkbox" id="autoScroll" checked>
Auto-scroll
</label>
</div>
<div class="log-container" id="log"></div>
</div>
</div>
<script> <script>
// State
let ws = null;
let eventCount = 0;
let currentRms = 0;
let rmsTimeout = null;
// Elements
const log = document.getElementById('log'); const log = document.getElementById('log');
const status = document.getElementById('status'); const status = document.getElementById('status');
const settingsPanel = document.getElementById('settings'); const settingsPanel = document.getElementById('settingsPanel');
const wsUrlInput = document.getElementById('wsUrl'); const wsUrlInput = document.getElementById('wsUrl');
const autoReconnectCheckbox = document.getElementById('autoReconnect'); const autoReconnectCheckbox = document.getElementById('autoReconnect');
let eventCount = 0; const rmsFill = document.getElementById('rmsFill');
let ws = null; const rmsValue = document.getElementById('rmsValue');
let autoReconnect = true;
function toggleSettings() { // Auto-detect WebSocket URL
settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none'; window.addEventListener('load', () => {
} const detectedUrl = `ws://${window.location.hostname}:8675`;
wsUrlInput.value = detectedUrl;
connect();
});
// Connect to WebSocket
function connect() { function connect() {
const wsUrl = wsUrlInput.value.trim(); const wsUrl = wsUrlInput.value.trim();
if (!wsUrl) { if (!wsUrl) {
addEvent('SYSTEM', { error: 'Please enter a WebSocket URL' }); addEvent('SYSTEM', { error: 'Please enter a WebSocket URL' });
return; return;
@@ -108,26 +496,32 @@
ws.close(); ws.close();
} }
setStatus('connecting', 'Connecting...');
addEvent('SYSTEM', { status: `Connecting to ${wsUrl}...` });
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
ws.onopen = () => { ws.onopen = () => {
status.textContent = 'Connected to ' + wsUrl; setStatus('connected', `Connected`);
status.className = 'connected'; addEvent('SYSTEM', { status: `Connected to ${wsUrl}` });
addEvent('SYSTEM', { status: 'Connected to ' + wsUrl });
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
addEvent(data.type, data.data); addEvent(data.type, data.data);
// Handle audio-level events
if (data.type === 'audio-level' && data.data && data.data.level !== undefined) {
updateRmsMeter(data.data.level);
}
} catch (e) { } catch (e) {
addEvent('RAW', { raw: event.data }); addEvent('RAW', { raw: event.data });
} }
}; };
ws.onclose = () => { ws.onclose = () => {
status.textContent = 'Disconnected'; setStatus('disconnected', 'Disconnected');
status.className = 'disconnected';
addEvent('SYSTEM', { status: 'Connection closed' }); addEvent('SYSTEM', { status: 'Connection closed' });
// Auto-reconnect // Auto-reconnect
@@ -140,39 +534,117 @@
}; };
ws.onerror = (err) => { ws.onerror = (err) => {
status.textContent = 'Error'; setStatus('disconnected', 'Error');
status.className = 'disconnected';
addEvent('SYSTEM', { error: 'WebSocket error', details: err.toString() }); addEvent('SYSTEM', { error: 'WebSocket error', details: err.toString() });
}; };
} }
// Auto-detect WebSocket URL from current page location on first load // Set status
window.addEventListener('load', () => { function setStatus(state, text) {
const detectedUrl = `ws://${window.location.hostname}:8675`; status.className = `status ${state}`;
wsUrlInput.value = detectedUrl; status.textContent = text;
connect(); }
// Send command
function sendCommand(command, data) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addEvent('SYSTEM', { error: 'Not connected to WebSocket server' });
return;
}
const message = { command, data };
ws.send(JSON.stringify(message));
addEvent('COMMAND', { command, data });
}
// Update volume
function updateVolume(value) {
document.getElementById('volumeValue').textContent = `${value}%`;
}
// Send volume command
document.getElementById('volumeSlider').addEventListener('change', function() {
const level = parseInt(this.value) / 100;
sendCommand('set_volume', { level });
}); });
// Update LED brightness
function updateLedBrightness(value) {
document.getElementById('ledBrightnessValue').textContent = value;
}
// Send LED brightness command
document.getElementById('ledBrightness').addEventListener('change', function() {
const brightness = parseInt(this.value);
sendCommand('set_led_brightness', { brightness });
});
// Update RMS meter
function updateRmsMeter(level) {
currentRms = level;
const percent = Math.min(100, Math.max(0, level * 100));
rmsFill.style.width = `${percent}%`;
rmsValue.textContent = level.toFixed(2);
// Reset after 1 second of no audio
clearTimeout(rmsTimeout);
rmsTimeout = setTimeout(() => {
currentRms = 0;
rmsFill.style.width = '0%';
rmsValue.textContent = '0.00';
}, 1000);
}
// Toggle settings
function toggleSettings() {
settingsPanel.classList.toggle('show');
}
// Add event to log
function addEvent(type, data) { function addEvent(type, data) {
eventCount++; eventCount++;
const time = new Date().toLocaleTimeString(); const time = new Date().toLocaleTimeString();
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'event'; div.className = `log-entry ${type.toLowerCase().replace('_', '-')}`;
div.innerHTML = `<span class="event-type">[${time}] #${eventCount} ${escapeHtml(type)}</span>\n<span class="event-data">${escapeHtml(JSON.stringify(data, null, 2))}</span>`;
log.appendChild(div); let dataStr = '';
log.scrollTop = log.scrollHeight; if (data.error) {
dataStr = `<span style="color: #ff4757;">${escapeHtml(data.error)}</span>`;
} else if (data.status) {
dataStr = `<span style="color: #00ff88;">${escapeHtml(data.status)}</span>`;
} else if (data.command) {
dataStr = `<span style="color: #ffc107;">→ ${escapeHtml(data.command)}</span> ${escapeHtml(JSON.stringify(data.data))}`;
} else {
dataStr = escapeHtml(JSON.stringify(data, null, 2));
} }
div.innerHTML = `
<span class="log-time">[${time}]</span>
<span class="log-type">#${eventCount} ${escapeHtml(type)}</span>
<span class="log-data">${dataStr}</span>
`;
log.appendChild(div);
// Auto-scroll
if (document.getElementById('autoScroll').checked) {
log.scrollTop = log.scrollHeight;
}
}
// Clear log
function clearLog() {
log.innerHTML = '';
eventCount = 0;
}
// Escape HTML
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function clearLog() {
log.innerHTML = '';
eventCount = 0;
}
</script> </script>
</body> </body>
</html> </html>
+4 -5
View File
@@ -165,13 +165,12 @@ def set_led_state(state: str, skip_publish=False):
logger.info(f"🟢 Speaking: зелёный ({brightness})") logger.info(f"🟢 Speaking: зелёный ({brightness})")
elif state == "idle": elif state == "idle":
# DoA эффект: яркость управляется через значения цвета # DoA эффект: яркость управляется отдельно через set_led_brightness
base_rgb = scale_color((0x30, 0x30, 0x30), brightness) base_color = rgb_to_bgr_int(0x30, 0x30, 0x30) # 0x303030 - серый
doa_rgb = scale_color((0x00, 0xBC, 0xD4), brightness) doa_color = rgb_to_bgr_int(0x00, 0xBC, 0xD4) # 0xD4BC00 - бирюзовый (BGR)
base_color = rgb_to_bgr_int(*base_rgb)
doa_color = rgb_to_bgr_int(*doa_rgb)
led_controller.set_led_effect(EFFECT_DOA) led_controller.set_led_effect(EFFECT_DOA)
led_controller.set_led_doa_color(base_color, doa_color) led_controller.set_led_doa_color(base_color, doa_color)
led_controller.set_led_brightness(brightness)
logger.info(f"⚪ Idle: DoA (brightness={brightness}, base=0x{base_color:06X}, doa=0x{doa_color:06X})") logger.info(f"⚪ Idle: DoA (brightness={brightness}, base=0x{base_color:06X}, doa=0x{doa_color:06X})")
elif state == "paused": elif state == "paused":
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
#
# Установка TEST systemd сервисов для Wyoming Satellite (dev branch)
# Автоматически определяет пути и подставляет их в service файлы
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_DIR="${SCRIPT_DIR}/services/test"
SYSTEMD_DIR="/etc/systemd/system"
echo "🔧 Установка TEST systemd сервисов для Wyoming Satellite (dev)"
echo "=============================================================="
# Определяем текущего пользователя
CURRENT_USER="${SUDO_USER:-$(whoami)}"
HOME_DIR=$(getent passwd "$CURRENT_USER" | cut -d: -f6)
echo "👤 Пользователь: $CURRENT_USER"
echo "🏠 Домашняя директория: $HOME_DIR"
# Ищем виртуальное окружение
# Сначала ищем в проекте satellite-dev (для test сервисов)
VENV_PATH=""
for candidate in \
"${SCRIPT_DIR}/.venv" \
"${SCRIPT_DIR}/.venv_satellite" \
"${HOME_DIR}/projects/.venv_satellite" \
"${HOME_DIR}/.venv_satellite"
do
if [ -f "${candidate}/bin/python3" ]; then
VENV_PATH="${candidate}"
break
fi
done
if [ -z "$VENV_PATH" ]; then
echo "❌ Виртуальное окружение не найдено!"
echo " Создайте его: cd ${SCRIPT_DIR} && ./setup_dev.sh"
exit 1
fi
echo "✅ VENV: $VENV_PATH"
# Создаём временную директорию для обработанных сервисов
TEMP_DIR=$(mktemp -d)
trap "rm -rf ${TEMP_DIR}" EXIT
# Обрабатываем каждый test service файл
for service_file in "${SERVICE_DIR}"/*.service; do
[ -f "$service_file" ] || continue
filename=$(basename "$service_file")
echo "📄 Обработка: $filename"
# Заменяем плейсхолдеры на актуальные пути
sed -e "s|__SAT_BASE_DIR__|${SCRIPT_DIR}|g" \
-e "s|__SAT_VENV__|${VENV_PATH}|g" \
-e "s|__CURRENT_USER__|${CURRENT_USER}|g" \
-e "s|__HOME_DIR__|${HOME_DIR}|g" \
"$service_file" > "${TEMP_DIR}/${filename}"
# Копируем в systemd
sudo cp "${TEMP_DIR}/${filename}" "${SYSTEMD_DIR}/"
echo " ✅ Установлен в ${SYSTEMD_DIR}/${filename}"
done
# Перезагружаем systemd
echo ""
echo "🔄 Перезагрузка systemd..."
sudo systemctl daemon-reload
echo ""
echo "✅ TEST сервисы установлены!"
echo ""
echo "📋 TEST сервис (не влияет на production):"
echo " - wyoming-satelite-vosk-test.service (спутник, порт 10702)"
echo ""
echo "💡 Production сервисы должны быть запущены:"
echo " - wyoming-wakeword-vosk.service (:10400)"
echo " - wyoming-event.service (:10500)"
echo ""
echo "🚀 Команды управления:"
echo " # Запуск test satellite"
echo " sudo systemctl start wyoming-satelite-vosk-test.service"
echo ""
echo " # Проверка статуса"
echo " sudo systemctl status wyoming-satelite-vosk-test.service"
echo ""
echo " # Остановка"
echo " sudo systemctl stop wyoming-satelite-vosk-test.service"
echo ""
echo "📊 Просмотр логов:"
echo " journalctl -u wyoming-satelite-vosk-test.service -f"
echo ""
echo "🌐 WebSocket для тестирования:"
echo " ws://127.0.0.1:8675"
echo ""
echo "💡 Пример подключения:"
echo " python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675"
echo ""
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
docker build . \
-t 'rhasspy/wyoming-satellite' "$@"
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
docker run \
-it \
-p 10700:10700 \
--device /dev/snd \
'rhasspy/wyoming-satellite' "$@"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Быстрый запуск TEST satellite в tmux
# Не требует установки systemd сервисов
# Production сервисы (wake word, event) должны быть запущены отдельно
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_PATH="${SCRIPT_DIR}/.venv"
PYTHON="${VENV_PATH}/bin/python3"
# Проверка venv
if [ ! -f "$PYTHON" ]; then
echo "❌ Виртуальное окружение не найдено: $VENV_PATH"
echo "💡 Запустите: cd ${SCRIPT_DIR} && ./setup_dev.sh"
exit 1
fi
echo "🚀 Запуск TEST satellite в tmux..."
echo "=================================="
echo ""
# Проверка tmux
if ! command -v tmux &> /dev/null; then
echo "❌ tmux не найден!"
echo "💡 Установите: sudo apt-get install tmux"
exit 1
fi
# Очистка старой сессии если существует
tmux kill-session -t satellite-test 2>/dev/null || true
# Создание новой сессии
echo "📡 Создание сессии 'satellite-test'..."
# Satellite (порт 10702 + WebSocket)
tmux new-session -d -s satellite-test -n "satellite" \
"cd ${SCRIPT_DIR} && source ${VENV_PATH}/bin/activate && \
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10702 \
--name 'Wyoming Satellite Test' \
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw' \
--event-uri tcp://127.0.0.1:10500 \
--wake-uri tcp://127.0.0.1:10400 \
--wake-word-name 'привет дройд' \
--websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled \
--audio-level-interval-ms 100 \
--debug"
echo " ✅ Satellite запущен (порт 10702 + WebSocket :8675)"
echo ""
echo "💡 Production сервисы должны быть запущены:"
echo " - Wake Word (:10400): sudo systemctl status wyoming-wakeword-vosk.service"
echo " - Event (:10500): sudo systemctl status wyoming-event.service"
echo ""
# Возврат к окну
tmux select-window -t satellite-test:0
echo "✅ Запуск завершён!"
echo ""
echo "📋 Сессия tmux: 'satellite-test'"
echo ""
echo "🎮 Управление tmux:"
echo " tmux attach -t satellite-test # Подключиться"
echo " tmux kill-session -t satellite-test # Остановить"
echo ""
echo "🌐 WebSocket:"
echo " ws://127.0.0.1:8675"
echo ""
echo "💡 Пример подключения:"
echo " source ${VENV_PATH}/bin/activate"
echo " python3 examples/websocket-service/test_client.py --uri ws://127.0.0.1:8675"
echo ""
@@ -0,0 +1,31 @@
[Unit]
Description=Wyoming Satellite Test (dev branch)
Requires=network.target
[Service]
Type=simple
User=__CURRENT_USER__
WorkingDirectory=__SAT_BASE_DIR__
# Запуск satellite с WebSocket и Audio Level
# Порт 10702 чтобы не конфликтовать с production (10700) и старым led (10701)
# Для wake word и event используются production сервисы (:10400, :10500)
ExecStart=__SAT_VENV__/bin/python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10702 \
--name 'Wyoming Satellite Test' \
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw' \
--event-uri tcp://127.0.0.1:10500 \
--wake-uri tcp://127.0.0.1:10400 \
--wake-word-name "привет дройд" \
--websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled \
--audio-level-interval-ms 100 \
--debug
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
VENV_PATH="${SCRIPT_DIR}/.venv"
echo " Настройка Python окружения..."
# Проверка Python
if ! command -v python3 &> /dev/null; then
echo "❌ Python3 не найден!" >&2
exit 1
fi
PYTHON_VERSION=$(python3 --version)
echo "✅ ${PYTHON_VERSION}"
# Создание venv
if [ -d "${VENV_PATH}" ]; then
echo "⚠️ .venv/ уже существует, пропускаю создание"
else
echo "📦 Создание виртуального окружения..."
python3 -m venv "${VENV_PATH}"
fi
# Активация и установка зависимостей
echo "📥 Установка зависимостей..."
"${VENV_PATH}/bin/pip" install --upgrade pip
"${VENV_PATH}/bin/pip" install -r "${SCRIPT_DIR}/requirements.txt"
echo ""
echo "✅ Готово!"
echo ""
echo "🚀 Запуск:"
echo " ./start_satellite.sh [wake|vad]"
echo ""
echo "🔧 Активация вручную:"
echo " source .venv/bin/activate"
-6
View File
@@ -1,6 +0,0 @@
/home/sirmrak/projects/.venv_satellite/bin/python local/vosk-wake-dev/script/run \
--uri tcp://0.0.0.0:10400 \
--model-path /home/sirmrak/projects/models/vosk-model-small-ru-0.22 \
--rate 16000 \
--wake-word "привет дроид" #\
# --debug
-6
View File
@@ -1,6 +0,0 @@
/home/sirmrak/wyoming-satellite/.venv/bin/python local/vosk-wake/script/run \
--uri tcp://0.0.0.0:10400 \
--model-path /home/sirmrak/models/vosk-model-small-ru-0.22 \
--rate 16000 \
--wake-word "привет дроид" \
--debug
+6 -15
View File
@@ -5,22 +5,13 @@ set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
export PYTHONPATH="${SCRIPT_DIR}" export PYTHONPATH="${SCRIPT_DIR}"
# Путь к Python-окружению (ищем в стандартных местах) # Путь к Python-окружению
VENV_PATH="" # Приоритет: .venv/ в корне проекта (локальное окружение)
for candidate in \ VENV_PATH="${SCRIPT_DIR}/.venv"
"${HOME}/projects/.venv_satellite" \
"${HOME}/.venv_satellite" \
"${SCRIPT_DIR}/.venv" \
"${SCRIPT_DIR}/.venv_satellite"
do
if [ -f "${candidate}/bin/python3" ]; then
VENV_PATH="${candidate}"
break
fi
done
if [ -z "$VENV_PATH" ]; then if [ ! -f "${VENV_PATH}/bin/python3" ]; then
echo "❌ Виртуальное окружение не найдено!" >&2 echo "❌ Виртуальное окружение не найдено: ${VENV_PATH}" >&2
echo "💡 Создайте: python3 -m venv ${VENV_PATH} && source ${VENV_PATH}/bin/activate && pip install -r requirements.txt" >&2
exit 1 exit 1
fi fi
BIN
View File
Binary file not shown.
+187
View File
@@ -0,0 +1,187 @@
# 🛰️ LVA NEXUS — Web Client для Wyoming Satellite
Современный веб-интерфейс для управления Wyoming Satellite через WebSocket.
## 🚀 Быстрый старт
### 1. Запустить satellite с WebSocket
```bash
cd ~/projects/satellite-dev
source .venv/bin/activate
python3 -m wyoming_satellite \
--uri tcp://0.0.0.0:10700 \
--websocket-enabled \
--websocket-port 8675 \
--audio-level-enabled \
--debug
```
### 2. Запустить HTTP сервер с паролем
```bash
cd ~/projects/satellite-dev/web-client
# С паролем по умолчанию (admin)
python3 start_server.py --port 8080
# Или со своим паролем
python3 start_server.py --port 8080 --password mysecretpassword
```
### 3. Открыть в браузере
```
http://localhost:8080/client.html
или
http://192.168.200.111:8080/client.html
```
**🔐 Ввести пароль** (по умолчанию: `admin`)
---
## 📋 Функции
| Функция | Статус | Описание |
|---------|--------|----------|
| 🔊 Громкость | ✅ | Установка громкости satellite (0-100%) |
| 🔇 Mute / 🔊 Unmute | ✅ | Заглушить/включить микрофон |
| 📊 RMS Audio Level | ✅ | Визуализация громкости в реальном времени |
| 💡 LED управление | ⏳ | Цвет, яркость, эффекты |
| 🎤 Start Listening | ⏳ | Запуск прослушивания |
| ⏹️ Stop Pipeline | ⏳ | Остановка pipeline |
| ⏲️ Stop Timer | ⏳ | Остановка таймера |
| 🗣️ Transcript | ⏳ | Отображение распознанного текста |
---
## 🎮 Управление
### Громкость
- **Слайдер** — плавная регулировка 0-100%
- **Кнопки VOL+ / VOL-** — пошаговое изменение
- **Mute/Unmute** — быстрое отключение микрофона
### LED управление
```javascript
// Яркость
sendCommand('set_led_brightness', { brightness: 200 });
// Цвет (RGB)
sendCommand('set_led_color', { r: 255, g: 0, b: 0 });
```
### Аудио визуализация
- **Canvas** — отрисовка аудио уровня в реальном времени
- **RMS метр** — числовое значение громкости
- **Орбита** — анимация состояния (idle, listening, speaking)
---
## 🔧 Настройки
### Подключение
При первом запуске введите WebSocket адрес:
```
ws://192.168.200.111:8675
```
### Авто-подключение
- **Auto-reconnect** — автоматическое переподключение при разрыве
- **Auto-scroll** — автоматическая прокрутка лога событий
---
## 📊 События
Web клиент получает от satellite:
| Событие | Описание |
|---------|----------|
| `voice-started` | Пользователь начал говорить |
| `voice-stopped` | Пользователь перестал говорить |
| `transcript` | Распознанный текст |
| `synthesize` | Запрос на синтез речи |
| `audio-level` | RMS громкость микрофона |
| `timer-started` | Таймер запущен |
| `timer-finished` | Таймер завершён |
| `error` | Ошибка |
---
## 🐛 Известные проблемы
| Проблема | Статус | Решение |
|----------|--------|---------|
| Громкость всегда 100% | ✅ Исправлено | Поддерживаются `level` и `volume` |
| WebSocket отключается | ⏳ В работе | Проверить firewall |
| RMS не обновляется | ⏳ В работе | Проверить audio-level-enabled |
---
## 🛠️ Разработка
### Структура
```
web-client/
├── client.html # Основной UI
├── README.md # Документация
└── start_server.py # HTTP сервер (опционально)
```
### Добавление команд
1. **В satellite.py** — обработчик команды:
```python
async def handle_websocket_command(self, command: str, data: dict):
if command == "my_command":
# обработка
```
2. **В client.html** — отправка команды:
```javascript
sendCommand('my_command', { param: value });
```
3. **В client.html** — обработка ответа:
```javascript
function handleIncomingMessage(ev) {
const json = JSON.parse(ev.data);
if(json.event === 'my_event') {
// обработка
}
}
```
---
## 📝 Changelog
### 2026-05-21
- ✅ Исправлен баг volume/level
- ✅ Добавлена поддержка обоих названий параметра
- ✅ Обновлён план разработки
### 2026-05-20
- ✅ Создан современный UI
- ✅ WebSocket интеграция
- ✅ RMS визуализация
- ✅ Лог событий
---
## 🔗 Связанное
- [WebSocket Server Plan](../docs/development/websocket-server-plan.md)
- [Wyoming Satellite](../README.md)
- [MQTT LED Integration](../docs/integration/mqtt-led.md)
File diff suppressed because it is too large Load Diff
Binary file not shown.
+396
View File
@@ -0,0 +1,396 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
/* ===== СЛОЙ 1: BODY — общий фон страницы ===== */
body {
background: radial-gradient(circle at 30% 20%, #0a0f1e, #03060c);
font-family: 'Segoe UI', 'Inter', system-ui, sans-serif;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
}
/* ===== Частицы ===== */
.particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
width: 2px;
height: 2px;
background: rgba(0, 255, 255, 0.3);
border-radius: 50%;
animation: float 20s infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0) translateX(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-100vh) translateX(50px); opacity: 0; }
}
/* ===== Контейнер ассистента ===== */
.assistant-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 40px;
padding: 40px;
max-width: 600px;
width: 100%;
}
/* ===== Бейдж подключения ===== */
.connection-badge {
position: absolute;
top: 30px;
right: 30px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
padding: 12px 20px;
border-radius: 50px;
border: 1px solid rgba(0, 255, 255, 0.3);
font-size: 14px;
color: #b0e0ff;
font-weight: 500;
z-index: 2;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ff4d4d;
box-shadow: 0 0 10px #ff4d4d;
transition: all 0.3s;
}
.status-dot.connected {
background: #0ff;
box-shadow: 0 0 20px #0ff;
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
}
/* ===== СЛОЙ 3: ANIMATION-WRAPPER — непрозрачный тёмный круг =====
isolation: isolate создаёт локальный stacking context,
чтобы ::before с z-index: -1 был ПОД wrapper, но НАД body
*/
.animation-wrapper {
position: relative;
width: 420px;
height: 420px;
border-radius: 50%;
isolation: isolate;
/* CSS-переменные для фона круга и ореола */
--bg-light: #1a2540;
--bg-dark: #03060c;
--glow-color: rgba(59, 130, 246, 0.55);
/* Непрозрачный тёмный фон — свой для каждого состояния */
background: radial-gradient(
circle at 30% 30%,
var(--bg-light) 0%,
#0a0f1e 50%,
var(--bg-dark) 100%
);
/* Лёгкая внутренняя подсветка (inset) */
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.5);
transition: background 0.8s ease;
}
/* ===== СЛОЙ 2: ::BEFORE — свечение-ореол, выходит за границы ===== */
.animation-wrapper::before {
content: '';
position: absolute;
/* Выходим за границы круга на 70px с каждой стороны */
inset: -70px;
border-radius: 50%;
/* Светящийся ореол */
background: radial-gradient(
circle at center,
var(--glow-color) 0%,
var(--glow-color) 30%,
rgba(0, 0, 0, 0) 70%
);
filter: blur(25px);
opacity: 0.85;
/* ПОД wrapper, но НАД body благодаря isolation: isolate */
z-index: -1;
pointer-events: none;
transition: background 0.8s ease, opacity 0.8s ease;
animation: halo-pulse 3s ease-in-out infinite;
}
@keyframes halo-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
/* ===== СЛОЙ 4: LOTTIE-CANVAS — анимация с прозрачным фоном ===== */
#lottie-canvas {
width: 100% !important;
height: 100% !important;
display: block;
transform: scale(2);
transform-origin: center center;
position: relative;
z-index: 2;
/* Обрезаем canvas по кругу, чтобы не вылезал за wrapper */
clip-path: circle(50% at center);
/* Фон прозрачный — видно непрозрачный фон wrapper под ним */
background: transparent;
}
/* ===== ЦВЕТА ДЛЯ КАЖДОГО СОСТОЯНИЯ ===== */
.animation-wrapper.idle {
--bg-light: #1a2540;
--bg-dark: #03060c;
--glow-color: rgba(59, 130, 246, 0.55);
}
.animation-wrapper.yes {
--bg-light: #0f2818;
--bg-dark: #030a06;
--glow-color: rgba(34, 197, 94, 0.65);
}
.animation-wrapper.no {
--bg-light: #2a1010;
--bg-dark: #0a0303;
--glow-color: rgba(239, 68, 68, 0.7);
}
.animation-wrapper.no::before {
animation: halo-pulse 1s ease-in-out infinite;
}
.animation-wrapper.alert {
--bg-light: #2a1f08;
--bg-dark: #0a0803;
--glow-color: rgba(245, 158, 11, 0.7);
}
.animation-wrapper.alert::before {
animation: halo-pulse 0.5s ease-in-out infinite;
}
.animation-wrapper.thinking {
--bg-light: #1a0f2a;
--bg-dark: #06030a;
--glow-color: rgba(168, 85, 247, 0.65);
}
.animation-wrapper.thinking::before {
animation: halo-pulse 1.5s ease-in-out infinite;
}
.animation-wrapper.jump {
--bg-light: #2a0f1f;
--bg-dark: #0a0306;
--glow-color: rgba(236, 72, 153, 0.75);
}
.animation-wrapper.jump::before {
animation: halo-pulse 0.3s ease-in-out infinite;
}
/* ===== Оверлей загрузки ===== */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 6, 12, 0.95);
z-index: 10;
border-radius: 50%;
transition: opacity 0.5s;
}
.loading-overlay.hidden { opacity: 0; pointer-events: none; }
/* ===== Текст статуса ===== */
.status-text {
font-size: 24px;
font-weight: 600;
color: #00ffff;
text-transform: uppercase;
letter-spacing: 4px;
text-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
animation: text-glow 2s ease-in-out infinite;
min-height: 30px;
}
@keyframes text-glow {
0%, 100% { text-shadow: 0 0 20px rgba(0, 255, 255, 0.5); }
50% { text-shadow: 0 0 40px rgba(0, 255, 255, 0.8); }
}
/* ===== Контейнер громкости ===== */
.volume-container {
width: 100%;
max-width: 400px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
border: 1px solid rgba(0, 255, 255, 0.3);
border-radius: 30px;
padding: 30px;
display: flex;
flex-direction: column;
gap: 20px;
}
.volume-header {
display: flex;
justify-content: space-between;
align-items: center;
color: #b0e0ff;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 2px;
}
.volume-value {
font-size: 24px;
font-weight: 700;
color: #00ffff;
font-family: 'Courier New', monospace;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
background: rgba(0, 255, 255, 0.2);
border-radius: 10px;
outline: none;
transition: all 0.3s;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px;
height: 24px;
background: #00ffff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.8);
}
/* ===== Кнопки громкости ===== */
.vol-btn {
background: rgba(0, 255, 255, 0.1);
border: 1px solid rgba(0, 255, 255, 0.5);
color: #00ffff;
padding: 12px 24px;
border-radius: 50px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.vol-btn:hover {
background: rgba(0, 255, 255, 0.3);
border-color: #00ffff;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
}
/* ===== Debug-кнопки ===== */
.debug-buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
margin-top: 15px;
}
.debug-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(0, 255, 255, 0.4);
color: #0ff;
padding: 8px 16px;
border-radius: 40px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: 0.2s;
}
.debug-btn:hover {
background: rgba(0, 255, 255, 0.2);
border-color: #0ff;
box-shadow: 0 0 10px rgba(0, 255, 255, 0.4);
}
/* ===== Transcript box ===== */
.transcript-box {
width: 100%;
max-width: 500px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(15px);
border: 1px solid rgba(0, 255, 255, 0.2);
border-radius: 20px;
padding: 20px;
min-height: 80px;
display: flex;
flex-direction: column;
gap: 10px;
}
.transcript-label {
font-size: 12px;
color: #7f9fcf;
text-transform: uppercase;
letter-spacing: 2px;
}
.transcript-text {
font-size: 18px;
color: #eef5ff;
font-style: italic;
line-height: 1.6;
}
/* ===== Адаптив ===== */
@media (max-width: 768px) {
.animation-wrapper { width: 280px; height: 280px; }
.status-text { font-size: 20px; }
}
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Droid Assistant</title>
<!-- Стили -->
<link rel="stylesheet" href="droidAssist.css">
</head>
<body>
<div class="particles" id="particles"></div>
<div class="connection-badge">
<div class="status-dot" id="statusDot"></div>
<span id="statusLabel">Отключено</span>
</div>
<div class="assistant-container">
<div class="animation-wrapper idle" id="animationWrapper">
<canvas id="lottie-canvas" width="700" height="700"></canvas>
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
</div>
</div>
<div class="status-text" id="statusText">В ОЖИДАНИИ</div>
<div class="volume-container">
<div class="volume-header">
<span>🔊 Громкость</span>
<span class="volume-value" id="volumeValue">80%</span>
</div>
<input type="range" class="volume-slider" id="volumeSlider" min="0" max="100" value="80" step="1">
<div class="volume-buttons">
<button class="vol-btn" id="volDownBtn">🔉 -10%</button>
<button class="vol-btn" id="volUpBtn">🔊 +10%</button>
</div>
</div>
<div class="debug-buttons">
<button class="debug-btn" onclick="goToState('yes')">👍 Yes</button>
<button class="debug-btn" onclick="goToState('thinking')">🤔 Think</button>
<button class="debug-btn" onclick="goToState('alert')">⚠️ Alert</button>
<button class="debug-btn" onclick="goToState('no')">❌ No</button>
<button class="debug-btn" onclick="goToState('jump')">🦘 Jump</button>
<button class="debug-btn" onclick="goToState('idle')">😐 Idle</button>
</div>
<div class="transcript-box">
<div class="transcript-label">💬 Последняя фраза</div>
<div class="transcript-text" id="transcriptText">—</div>
</div>
</div>
<!-- Скрипт как модуль (нужен для импорта DotLottie) -->
<script type="module" src="droidAssist.js"></script>
</body>
</html>
+244
View File
@@ -0,0 +1,244 @@
import { DotLottie } from 'https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.0/+esm';
// ==================== КОНФИГУРАЦИЯ ====================
const WS_URL = 'ws://192.168.200.111:8675';
const NUM_PARTICLES = 30;
const LOTTIE_SRC = 'animation.lottie';
// ==================== СЕГМЕНТЫ АНИМАЦИИ ====================
const segments = {
idle: { start: 0, end: 29 },
yes: { start: 31, end: 105 },
no: { start: 106, end: 180 },
alert: { start: 181, end: 270 },
thinking: { start: 271, end: 390 },
jump: { start: 391, end: 479 }
};
// ==================== СОСТОЯНИЕ ====================
let ws = null;
let isConnected = false;
let currentVolume = 0.8;
let dotLottie = null;
let currentState = 'idle';
let animationReady = false;
// ==================== DOM ====================
const statusDot = document.getElementById('statusDot');
const statusLabel = document.getElementById('statusLabel');
const wrapper = document.getElementById('animationWrapper');
const statusText = document.getElementById('statusText');
const volumeSlider = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const volUpBtn = document.getElementById('volUpBtn');
const volDownBtn = document.getElementById('volDownBtn');
const transcriptText = document.getElementById('transcriptText');
// ==================== ЧАСТИЦЫ ====================
function initParticles() {
const particles = document.getElementById('particles');
for (let i = 0; i < NUM_PARTICLES; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 20 + 's';
particle.style.animationDuration = (15 + Math.random() * 10) + 's';
particles.appendChild(particle);
}
}
// ==================== УПРАВЛЕНИЕ АНИМАЦИЕЙ ====================
window.goToState = function(stateName, statusLabelText) {
if (!animationReady || !dotLottie) return;
currentState = stateName;
const seg = segments[stateName];
if (!seg) return;
dotLottie.stop();
dotLottie.setSegment(seg.start, seg.end);
if (stateName === 'idle') {
dotLottie.setSpeed(0.1);
} else {
dotLottie.setSpeed(1);
}
dotLottie.setLoop(stateName === 'idle');
dotLottie.play();
if (statusLabelText !== undefined) {
statusText.textContent = statusLabelText;
}
// Обновляем класс для свечения фона
wrapper.className = `animation-wrapper ${stateName}`;
};
// ==================== МАППИНГ СОБЫТИЙ SATELLITE → АНИМАЦИЯ ====================
function handleSatelliteEvent(eventType, eventData) {
const data = eventData || {};
switch (eventType) {
// ─── yes: СЛУШАЮ, ГОВОРЮ, АКТИВЕН ───
case 'voice-started':
case 'stt-start':
goToState('yes', 'СЛУШАЮ');
break;
case 'tts-start':
case 'audio-start':
goToState('yes', 'ГОВОРЮ');
break;
case 'streaming-started':
goToState('yes', 'АКТИВЕН');
break;
// ─── thinking: ОБРАБОТКА ───
case 'voice-stopped':
case 'stt-stop':
goToState('thinking', 'ОБРАБОТКА');
break;
// ─── idle: В ОЖИДАНИИ ───
case 'played':
case 'tts-stop':
case 'audio-stop':
goToState('idle', 'В ОЖИДАНИИ');
break;
// ─── no: ОШИБКА, СПЛЮ ───
case 'satellite-disconnected':
case 'error':
goToState('no', 'ОШИБКА');
break;
case 'pause-satellite':
goToState('no', 'СПЛЮ...');
break;
// ─── jump: ПРИВЕТ!!! ───
case 'detection':
goToState('jump', 'ПРИВЕТ!!!');
break;
// ─── transcript / synthesize ───
case 'transcript':
if (data.text) {
transcriptText.textContent = `"${data.text}"`;
goToState('yes');
}
break;
case 'synthesize':
if (data.text) {
transcriptText.textContent = `"${data.text}"`;
goToState('yes');
}
break;
// ─── audio-level ───
case 'audio-level':
break;
default:
if (eventType !== 'audio-level') {
console.log('Unhandled event:', eventType, data);
}
break;
}
}
// ==================== WEBSOCKET ====================
function connectWebSocket() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
isConnected = true;
statusDot.classList.add('connected');
statusLabel.textContent = 'Подключено';
console.log('✅ WebSocket connected');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const eventType = data.type || data.event;
const eventData = data.data || {};
if (eventType != "audio-level") {
console.info('EventType:', eventType);
console.info('EventData:', eventData);
}
handleSatelliteEvent(eventType, eventData);
} catch (e) {
console.error('Parse error:', e);
}
};
ws.onerror = (error) => console.error('WebSocket error:', error);
ws.onclose = () => {
isConnected = false;
statusDot.classList.remove('connected');
statusLabel.textContent = 'Отключено';
handleSatelliteEvent('satellite-disconnected');
console.log('❌ WebSocket disconnected');
setTimeout(connectWebSocket, 3000);
};
}
// ==================== ГРОМКОСТЬ ====================
function updateVolume(newVolume) {
currentVolume = Math.max(0, Math.min(1, newVolume));
volumeSlider.value = Math.round(currentVolume * 100);
volumeValue.textContent = Math.round(currentVolume * 100) + '%';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
command: 'set_volume',
data: { level: currentVolume }
}));
}
}
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
async function init() {
const canvas = document.getElementById('lottie-canvas');
const overlay = document.getElementById('loadingOverlay');
initParticles();
connectWebSocket();
dotLottie = new DotLottie({
canvas: canvas,
src: LOTTIE_SRC,
autoplay: false,
loop: true,
renderConfig: { preserveAspectRatio: 'xMidYMid meet' }
});
dotLottie.addEventListener('load', () => {
animationReady = true;
overlay.classList.add('hidden');
goToState('idle', 'В ОЖИДАНИИ');
});
dotLottie.addEventListener('complete', () => {
if (currentState !== 'idle') {
goToState('idle', 'В ОЖИДАНИИ');
}
});
// Volume controls
volumeSlider.addEventListener('input', (e) => updateVolume(e.target.value / 100));
volUpBtn.addEventListener('click', () => updateVolume(currentVolume + 0.1));
volDownBtn.addEventListener('click', () => updateVolume(currentVolume - 0.1));
}
// Запуск после загрузки страницы
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Simple HTTP server for WebSocket client testing with password protection.
Usage:
python3 start_web_server.py [--port PORT] [--password PASSWORD]
Open in browser:
http://localhost:PORT/client.html
"""
import argparse
import base64
import http.server
import socketserver
import os
import sys
import threading
from pathlib import Path
PORT = 8080
SCRIPT_DIR = Path(__file__).parent.resolve()
# Default password
DEFAULT_PASSWORD = "admin"
class AuthHandler(http.server.SimpleHTTPRequestHandler):
"""HTTP handler with Basic Auth."""
def __init__(self, *args, password="admin", **kwargs):
self.password = password
super().__init__(*args, **kwargs)
def check_auth(self):
"""Check Basic Auth credentials."""
auth_header = self.headers.get('Authorization')
if not auth_header:
return False
try:
# Decode "Basic base64(username:password)"
auth_type, auth_string = auth_header.split(' ', 1)
if auth_type.lower() != 'basic':
return False
decoded = base64.b64decode(auth_string).decode('utf-8')
username, password = decoded.split(':', 1)
# For now, we only check password (username is ignored)
return password == self.password
except Exception:
return False
def send_auth_request(self):
"""Send 401 Unauthorized response."""
self.send_response(401)
self.send_header('WWW-Authenticate', 'Basic realm="Wyoming Satellite"')
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
html = '''
<html>
<head><title>401 Unauthorized</title></head>
<body>
<h1>Wyoming Satellite - Authentication Required</h1>
<p>Please enter the password to access the Web UI.</p>
</body>
</html>
'''
self.wfile.write(html.encode('utf-8'))
def do_GET(self):
"""Handle GET requests with authentication."""
# Skip auth for static files that are already protected
if not self.check_auth():
self.send_auth_request()
return
super().do_GET()
print(f"[WebServer] ✅ {self.requestline} → authenticated")
def log_message(self, format, *args):
# Suppress default logging
pass
def create_auth_handler(password):
"""Create auth handler with password."""
class Handler(AuthHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, password=password, **kwargs)
return Handler
class ReusableTCPServer(socketserver.TCPServer):
"""TCP Server with address reuse enabled."""
allow_reuse_address = True
def main():
parser = argparse.ArgumentParser(description='WebSocket Client Web Server with Password Protection')
parser.add_argument('--port', type=int, default=PORT, help=f'Port (default: {PORT})')
parser.add_argument('--host', default='0.0.0.0', help='Host (default: 0.0.0.0)')
parser.add_argument('--password', default=DEFAULT_PASSWORD, help=f'Password (default: {DEFAULT_PASSWORD})')
args = parser.parse_args()
# Change to script directory
os.chdir(SCRIPT_DIR)
# Create handler with password
Handler = create_auth_handler(args.password)
print("""
+==========================================================+
| Wyoming Satellite WebSocket Client Server |
+==========================================================+
| Serving: {:<53} |
| URL: http://localhost:{:<29} |
| Remote: http://<your-ip>:{:<25} |
| Password: {:<48} |
| |
| Press Ctrl+C to stop |
+==========================================================+
""".format(
str(SCRIPT_DIR),
args.port,
args.port,
'*' * len(args.password)
))
try:
with ReusableTCPServer((args.host, args.port), Handler) as httpd:
print(f"Server started on http://{args.host}:{args.port}")
print("Serving files from:", SCRIPT_DIR)
print(f"Password protected ({len(args.password)} characters)")
print("\nPress Ctrl+C to stop\n")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nShutting down gracefully...")
# Shutdown in a separate thread to avoid deadlock
shutdown_thread = threading.Thread(target=httpd.shutdown)
shutdown_thread.start()
shutdown_thread.join(timeout=2)
httpd.server_close()
print("Server stopped successfully")
except OSError as e:
print(f"\nError: Could not bind to port {args.port}")
print(f" {e}")
print("\nTry using a different port:")
print(f" python3 {sys.argv[0]} --port 8081\n")
sys.exit(1)
except Exception as e:
print(f"\nUnexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+40
View File
@@ -257,6 +257,37 @@ async def main() -> None:
help="Command to run when a timer finishes", help="Command to run when a timer finishes",
) )
# WebSocket server
parser.add_argument(
"--websocket-enabled",
action="store_true",
help="Enable WebSocket server for external clients (default: disabled)"
)
parser.add_argument(
"--websocket-host",
default="0.0.0.0",
help="Host for WebSocket server (default: 0.0.0.0)"
)
parser.add_argument(
"--websocket-port",
type=int,
default=8675,
help="Port for WebSocket server (default: 8675)"
)
# Audio level monitoring
parser.add_argument(
"--audio-level-enabled",
action="store_true",
help="Enable audio level monitoring and send audio-level events to WebSocket"
)
parser.add_argument(
"--audio-level-interval-ms",
type=int,
default=100,
help="Interval for audio-level events in milliseconds (default: 100)"
)
# Sounds # Sounds
parser.add_argument( parser.add_argument(
"--awake-wav", help="WAV file to play when wake word is detected" "--awake-wav", help="WAV file to play when wake word is detected"
@@ -428,6 +459,15 @@ async def main() -> None:
error=split_command(args.error_command), error=split_command(args.error_command),
connected=split_command(args.connected_command), connected=split_command(args.connected_command),
disconnected=split_command(args.disconnected_command), disconnected=split_command(args.disconnected_command),
# WebSocket server
websocket_enabled=args.websocket_enabled,
websocket_host=args.websocket_host,
websocket_port=args.websocket_port,
# Audio level
audio_level_enabled=args.audio_level_enabled,
audio_level_interval_ms=args.audio_level_interval_ms,
), ),
timer=TimerSettings( timer=TimerSettings(
started=split_command(args.timer_started_command), started=split_command(args.timer_started_command),
+410 -14
View File
@@ -2,6 +2,7 @@
import array import array
import asyncio import asyncio
import json
import logging import logging
import math import math
import time import time
@@ -49,8 +50,19 @@ from .utils import (
from .vad import SileroVad, WebRtcVadEnergy from .vad import SileroVad, WebRtcVadEnergy
from .webrtc import WebRtcAudio from .webrtc import WebRtcAudio
try:
import websockets
WEBSOCKETS_AVAILABLE = True
except ImportError:
websockets = None # type: ignore
WEBSOCKETS_AVAILABLE = False
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
# Уменьшаем спам в логах от websockets библиотеки
# Показываем только важные сообщения, кроме audio-level
logging.getLogger('websockets.server').setLevel(logging.WARNING)
_PONG_TIMEOUT: Final = 5 _PONG_TIMEOUT: Final = 5
_PING_SEND_DELAY: Final = 2 _PING_SEND_DELAY: Final = 2
_WAKE_INFO_TIMEOUT: Final = 2 _WAKE_INFO_TIMEOUT: Final = 2
@@ -100,6 +112,25 @@ class SatelliteBase:
self.microphone_muted = False self.microphone_muted = False
self._unmute_microphone_task: Optional[asyncio.Task] = None self._unmute_microphone_task: Optional[asyncio.Task] = None
# Runtime настройки (изменяемые через WebSocket команды)
self._runtime_mic_volume: float = 1.0 # Громкость микрофона
self._runtime_snd_volume: float = 1.0 # Громкость воспроизведения
self._runtime_led_brightness: int = 128
# WebSocket server
self._websocket_server: Optional["websockets.server.Serve"] = None
self._websocket_clients: Set[websockets.server.WebSocketServerProtocol] = set() # type: ignore
self._websocket_lock = asyncio.Lock()
# Audio level monitoring
self._last_mic_level: float = 0.0
self._audio_level_task: Optional[asyncio.Task] = None
# Audio recording
self._is_recording: bool = False
self._recording_buffer: List[bytes] = []
self._recording_start_time: Optional[float] = None
# Debug audio recording # Debug audio recording
self.wake_audio_writer: Optional[DebugAudioWriter] = None self.wake_audio_writer: Optional[DebugAudioWriter] = None
self.stt_audio_writer: Optional[DebugAudioWriter] = None self.stt_audio_writer: Optional[DebugAudioWriter] = None
@@ -234,6 +265,17 @@ class SatelliteBase:
"""Connect to services.""" """Connect to services."""
self.state = State.STARTING self.state = State.STARTING
await self._connect_to_services() await self._connect_to_services()
# Запустить WebSocket сервер (новый)
if self.settings.event.websocket_enabled:
await self.start_websocket_server()
# Запустить задачу audio level (новый)
if self.settings.event.audio_level_enabled:
self._audio_level_task = asyncio.create_task(
self._send_audio_level(), name="audio_level"
)
self.state = State.STARTED self.state = State.STARTED
await self.started() await self.started()
@@ -245,6 +287,15 @@ class SatelliteBase:
self.server_id = None self.server_id = None
self._writer = None self._writer = None
# Остановить WebSocket сервер (новый)
if self._websocket_server:
await self.stop_websocket_server()
# Остановить задачу audio level (новый)
if self._audio_level_task:
self._audio_level_task.cancel()
self._audio_level_task = None
await self._disconnect_from_services() await self._disconnect_from_services()
self._disable_ping() self._disable_ping()
self.state = State.STOPPED self.state = State.STOPPED
@@ -546,6 +597,10 @@ class SatelliteBase:
audio_bytes, self.settings.mic.volume_multiplier audio_bytes, self.settings.mic.volume_multiplier
) )
# Применить runtime громкость микрофона (из WebSocket команд)
if self._runtime_mic_volume != 1.0:
audio_bytes = multiply_volume(audio_bytes, self._runtime_mic_volume)
if self._mic_webrtc is not None: if self._mic_webrtc is not None:
# Noise suppression and auto gain # Noise suppression and auto gain
audio_bytes = self._mic_webrtc(audio_bytes) audio_bytes = self._mic_webrtc(audio_bytes)
@@ -604,9 +659,13 @@ class SatelliteBase:
_LOGGER.debug("Connected to snd service") _LOGGER.debug("Connected to snd service")
# Audio processing # Audio processing
if self.settings.snd.needs_processing and AudioChunk.is_type( # Проверяем нужна ли обработка: базовый volume_multiplier или runtime громкость
event.type needs_processing = (
): self.settings.snd.needs_processing # volume_multiplier != 1.0
or self._runtime_snd_volume != 1.0 # WebSocket громкость
)
if needs_processing and AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event) chunk = AudioChunk.from_event(event)
audio_bytes = self._process_snd_audio(chunk.audio) audio_bytes = self._process_snd_audio(chunk.audio)
event = AudioChunk( event = AudioChunk(
@@ -644,6 +703,11 @@ class SatelliteBase:
audio_bytes, self.settings.snd.volume_multiplier audio_bytes, self.settings.snd.volume_multiplier
) )
# Применить runtime громкость воспроизведения (из WebSocket команд)
if self._runtime_snd_volume != 1.0:
_LOGGER.debug(f"Applying runtime volume: {self._runtime_snd_volume}")
audio_bytes = multiply_volume(audio_bytes, self._runtime_snd_volume)
return audio_bytes return audio_bytes
async def _play_wav( async def _play_wav(
@@ -916,10 +980,20 @@ class SatelliteBase:
await asyncio.sleep(self.settings.timer.finished_wav_delay) await asyncio.sleep(self.settings.timer.finished_wav_delay)
async def forward_event(self, event: Event) -> None: async def forward_event(self, event: Event) -> None:
"""Forward an event to the event service.""" """
Forward an event to all event consumers.
Sends to:
- Legacy event service (if configured via event.uri)
- All connected WebSocket clients
"""
# Legacy event service (через TCP клиент)
if self._event_queue is not None: if self._event_queue is not None:
self._event_queue.put_nowait(event) self._event_queue.put_nowait(event)
# WebSocket клиенты
await self._broadcast_to_websocket(event)
def _make_event_client(self) -> Optional[AsyncClient]: def _make_event_client(self) -> Optional[AsyncClient]:
"""Create client for event service.""" """Create client for event service."""
if self.settings.event.uri: if self.settings.event.uri:
@@ -951,11 +1025,21 @@ class SatelliteBase:
await event_client.connect() await event_client.connect()
_LOGGER.debug("Connected to event service") _LOGGER.debug("Connected to event service")
try:
await event_client.write_event(event) await event_client.write_event(event)
except (ConnectionResetError, BrokenPipeError, OSError) as err:
# Event service disconnected, reconnect on next event
_LOGGER.warning("Event service disconnected: %s", err)
await _disconnect()
event_client = None
# Put event back in queue to retry after reconnect
self._event_queue.put_nowait(event)
await asyncio.sleep(self.settings.event.reconnect_seconds)
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception: except Exception:
_LOGGER.exception("Unexpected error in event read task") _LOGGER.exception("Unexpected error in event task")
await _disconnect() await _disconnect()
event_client = None # reconnect event_client = None # reconnect
self._event_queue = None self._event_queue = None
@@ -963,6 +1047,306 @@ class SatelliteBase:
await _disconnect() await _disconnect()
# -------------------------------------------------------------------------
# WebSocket Server
# -------------------------------------------------------------------------
async def start_websocket_server(self) -> None:
"""
Запустить WebSocket сервер для внешних клиентов.
Клиенты могут подключаться к порту websocket_port и получать все события,
а также отправлять команды (set_volume, mute, play_sound, etc.).
"""
if not WEBSOCKETS_AVAILABLE:
_LOGGER.warning("websockets package not installed, skipping WebSocket server")
return
async def handle_websocket(websocket): # type: ignore
"""
Handle WebSocket connection.
Note: websockets 16.0+ doesn't pass 'path' argument anymore.
Use websocket.path if needed.
"""
client_addr = websocket.remote_address
_LOGGER.info(f"WebSocket client connected: {client_addr}")
async with self._websocket_lock:
self._websocket_clients.add(websocket)
try:
# Ждать команды от клиента
async for message in websocket:
try:
data = json.loads(message)
command = data.get('command')
command_data = data.get('data', {})
if command:
# Логируем все команды кроме audio-level
if command != "audio-level":
_LOGGER.debug(f"WebSocket RX: {command} {command_data}")
await self.handle_websocket_command(command, command_data)
else:
_LOGGER.debug(f"Received message (no command): {data}")
except json.JSONDecodeError:
_LOGGER.warning(f"Invalid JSON from WebSocket: {message}")
except Exception as exc:
_LOGGER.error(f"Error processing command: {exc}")
except websockets.exceptions.ConnectionClosed: # type: ignore
pass # Нормальное отключение
except Exception as exc:
_LOGGER.debug(f"WebSocket client error: {exc}")
finally:
async with self._websocket_lock:
self._websocket_clients.discard(websocket)
_LOGGER.info(f"WebSocket client disconnected: {client_addr}")
self._websocket_server = await websockets.serve( # type: ignore
handle_websocket,
self.settings.event.websocket_host,
self.settings.event.websocket_port
)
_LOGGER.info(
f"WebSocket server listening on "
f"{self.settings.event.websocket_host}:{self.settings.event.websocket_port}"
)
async def stop_websocket_server(self) -> None:
"""
Остановить WebSocket сервер и закрыть все подключения.
"""
_LOGGER.info("Stopping WebSocket server...")
if self._websocket_server:
self._websocket_server.close()
await self._websocket_server.wait_closed()
self._websocket_server = None
# Закрыть все подключения
async with self._websocket_lock:
for ws in self._websocket_clients:
await ws.close(1000, "Server shutting down")
self._websocket_clients.clear()
_LOGGER.info("WebSocket server stopped")
async def _broadcast_to_websocket(self, event: Event) -> None:
"""
Отправить событие всем WebSocket клиентам.
Мёртвые подключения автоматически удаляются.
"""
import json
async with self._websocket_lock:
if not self._websocket_clients:
return
dead_clients = []
message = json.dumps(
{"type": event.type, "data": event.data or {}},
ensure_ascii=False
)
# Логируем только важные события (не audio-level)
if event.type != "audio-level":
_LOGGER.debug(f"WebSocket TX: {event.type}")
for ws in self._websocket_clients:
try:
await ws.send(message)
except Exception:
_LOGGER.debug(
f"Failed to send event to WebSocket client, removing"
)
dead_clients.append(ws)
# Удалить мёртвые подключения
for ws in dead_clients:
self._websocket_clients.discard(ws)
async def handle_websocket_command(self, command: str, data: dict) -> None:
"""
Обработать команду от WebSocket клиента.
Поддерживаемые команды:
- set_volume: Изменить громкость воспроизведения (TTS, звуки)
- set_mic_volume: Изменить чувствительность микрофона
- mute: Заглушить микрофон
- unmute: Включить микрофон
- play_sound: Воспроизвести WAV файл
- set_led_brightness: Яркость LED
- trigger_event: Отправить событие в Home Assistant
"""
try:
if command == "set_volume":
# Изменить громкость воспроизведения (0.0 - 1.0)
# Поддерживаем оба названия: "level" и "volume" (для обратной совместимости)
level = float(data.get("level") or data.get("volume", 1.0))
level = max(0.0, min(1.0, level)) # Clamp 0-1
self._runtime_snd_volume = level
_LOGGER.info(f"Setting playback volume to {level}")
elif command == "set_mic_volume":
# Изменить чувствительность микрофона (0.0 - 2.0)
level = float(data.get("level") or data.get("volume", 1.0))
level = max(0.0, min(2.0, level)) # Clamp 0-2
self._runtime_mic_volume = level
_LOGGER.info(f"Setting microphone sensitivity to {level}")
elif command == "mute":
_LOGGER.info("Muting microphone")
self.microphone_muted = True
elif command == "unmute":
_LOGGER.info("Unmuting microphone")
self.microphone_muted = False
elif command == "play_sound":
# Воспроизвести WAV файл
wav_path = data.get("path")
if wav_path:
_LOGGER.info(f"Playing sound: {wav_path}")
await self._play_wav(wav_path, mute_microphone=False)
else:
_LOGGER.warning("play_sound command missing 'path'")
elif command == "set_led_brightness":
# Сохранить яркость LED
brightness = int(data.get("brightness", 128))
brightness = max(0, min(255, brightness)) # Clamp 0-255
self._runtime_led_brightness = brightness
_LOGGER.info(f"Setting LED brightness to {brightness}")
elif command == "start_recording":
# Начать запись аудио с микрофона
_LOGGER.info("Starting audio recording")
self._is_recording = True
self._recording_buffer = []
self._recording_start_time = time.time()
elif command == "stop_recording":
# Остановить запись и вернуть WAV файл
if not self._is_recording:
_LOGGER.warning("Recording not started")
return
_LOGGER.info("Stopping audio recording")
self._is_recording = False
# Создать WAV файл из буфера
duration = time.time() - (self._recording_start_time or 0)
wav_data = await self._create_wav_from_buffer()
# Отправить событие с данными записи
import base64
recording_event = Event(
type="recording-ready",
data={
"duration": duration,
"size": len(wav_data),
"data": base64.b64encode(wav_data).decode('utf-8')
}
)
await self._broadcast_to_websocket(recording_event)
# Очистить буфер
self._recording_buffer = []
_LOGGER.info(f"Recording saved: {duration:.1f}s, {len(wav_data)} bytes")
elif command == "trigger_event":
# Отправить событие в Home Assistant
event_type = data.get("event_type")
event_data = data.get("data", {})
if event_type:
_LOGGER.info(f"Triggering event: {event_type}")
# Отправить событие в server
trigger_event = Event(type=event_type, data=event_data)
await self.event_to_server(trigger_event)
else:
_LOGGER.warning("trigger_event missing 'event_type'")
else:
_LOGGER.warning(f"Unknown command: {command}")
except Exception as exc:
_LOGGER.error(f"Error handling command {command}: {exc}")
# -------------------------------------------------------------------------
# Audio Level Monitoring
# -------------------------------------------------------------------------
def _calculate_rms(self, audio_bytes: bytes) -> float:
"""
Вычислить RMS громкость (0.0 - 1.0).
Использует тот же алгоритм что и WebRtcVadEnergy из vad.py
"""
pcm = array.array("h")
pcm.frombytes(audio_bytes)
if not pcm:
return 0.0
rms = math.sqrt(sum(int(s) * int(s) for s in pcm) / len(pcm))
return rms / 32767.0 # Нормализация для 16-bit
async def _send_audio_level(self) -> None:
"""
Периодически отправлять audio-level события в WebSocket.
"""
from wyoming.event import Event
interval = self.settings.event.audio_level_interval_ms / 1000.0
while self.is_running:
await asyncio.sleep(interval)
if (
self.settings.event.audio_level_enabled
and self._websocket_clients
):
event = Event(
type="audio-level",
data={"level": self._last_mic_level}
)
await self._broadcast_to_websocket(event)
# Логировать только если уровень выше порога (чтобы не спамить)
if self._last_mic_level > 0.05:
_LOGGER.debug(f"Audio level: {self._last_mic_level:.2f}")
# -------------------------------------------------------------------------
# Audio Recording
# -------------------------------------------------------------------------
async def _create_wav_from_buffer(self) -> bytes:
"""
Создать WAV файл из буфера записи.
Returns:
bytes: WAV файл в виде байтов
"""
import io
if not self._recording_buffer:
return b''
# Объединить все чанки
audio_data = b''.join(self._recording_buffer)
# Создать WAV файл в памяти
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(self.settings.mic.channels)
wav_file.setsampwidth(self.settings.mic.width)
wav_file.setframerate(self.settings.mic.rate)
wav_file.writeframes(audio_data)
return wav_buffer.getvalue()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Info # Info
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -1022,15 +1406,18 @@ class AlwaysStreamingSatellite(SatelliteBase):
return return
if AudioChunk.is_type(event.type): if AudioChunk.is_type(event.type):
# Вычислить RMS для аудио чанка
if audio_bytes is None:
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
self._last_mic_level = self._calculate_rms(audio_bytes)
# Forward to server # Forward to server
await self.event_to_server(event) await self.event_to_server(event)
# Debug audio recording # Debug audio recording
if self.stt_audio_writer is not None: if self.stt_audio_writer is not None:
if audio_bytes is None:
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
self.stt_audio_writer.write(audio_bytes) self.stt_audio_writer.write(audio_bytes)
@@ -1114,13 +1501,15 @@ class VadStreamingSatellite(SatelliteBase):
# Only unpack chunk once # Only unpack chunk once
chunk: Optional[AudioChunk] = None chunk: Optional[AudioChunk] = None
# Debug audio recording # Вычислить RMS для аудио чанка
if self.stt_audio_writer is not None:
if audio_bytes is None: if audio_bytes is None:
# Need to unpack
chunk = AudioChunk.from_event(event) chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio audio_bytes = chunk.audio
self._last_mic_level = self._calculate_rms(audio_bytes)
# Debug audio recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.write(audio_bytes) self.stt_audio_writer.write(audio_bytes)
if ( if (
@@ -1320,12 +1709,19 @@ class WakeStreamingSatellite(SatelliteBase):
): ):
return return
# Debug audio recording # Вычислить RMS для аудио чанка
if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None):
if audio_bytes is None: if audio_bytes is None:
chunk = AudioChunk.from_event(event) chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio audio_bytes = chunk.audio
self._last_mic_level = self._calculate_rms(audio_bytes)
# Сохранить в буфер записи (если запись активна)
if self._is_recording:
self._recording_buffer.append(audio_bytes)
# Debug audio recording
if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None):
if self.wake_audio_writer is not None: if self.wake_audio_writer is not None:
self.wake_audio_writer.write(audio_bytes) self.wake_audio_writer.write(audio_bytes)
+17
View File
@@ -178,6 +178,23 @@ class EventSettings(ServiceSettings):
connected: Optional[List[str]] = None connected: Optional[List[str]] = None
disconnected: Optional[List[str]] = None disconnected: Optional[List[str]] = None
# WebSocket server settings
websocket_enabled: bool = False
"""True если WebSocket сервер должен быть запущен."""
websocket_host: str = "0.0.0.0"
"""Host для WebSocket сервера."""
websocket_port: int = 8675
"""Port для WebSocket сервера."""
# Audio level monitoring
audio_level_enabled: bool = False
"""True если нужно вычислять и отправлять RMS громкость микрофона."""
audio_level_interval_ms: int = 100
"""Интервал отправки audio-level событий (миллисекунды)."""
@dataclass(frozen=True) @dataclass(frozen=True)
class TimerSettings: class TimerSettings:
+8 -6
View File
@@ -38,6 +38,7 @@ USB_VENDOR_ID = 0x2886
USB_PRODUCT_IDS = [0x0018, 0x001A] USB_PRODUCT_IDS = [0x0018, 0x001A]
# GPO_SERVICER_RESID LED commands # GPO_SERVICER_RESID LED commands
# resid=20 (GPO service), cmdid=12-19 (LED commands)
GPO_RESID = 20 GPO_RESID = 20
LED_EFFECT_CMD = 12 LED_EFFECT_CMD = 12
LED_BRIGHTNESS_CMD = 13 LED_BRIGHTNESS_CMD = 13
@@ -152,18 +153,19 @@ class XVF3800Controller:
_LOGGER.debug("Looking for PID %04x...", pid) _LOGGER.debug("Looking for PID %04x...", pid)
dev = usb.core.find(idVendor=self._vendor_id, idProduct=pid) dev = usb.core.find(idVendor=self._vendor_id, idProduct=pid)
if dev is not None: if dev is not None:
try: # НЕ делаем detach_kernel_driver() — это отключает audio интерфейсы!
if dev.is_kernel_driver_active(0): # Control transfer с CTRL_TYPE_VENDOR работает без claim interface
_LOGGER.info("Detaching kernel driver from device...")
dev.detach_kernel_driver(0)
except Exception as exc:
_LOGGER.warning("Could not detach kernel driver: %s", exc)
self._dev = dev self._dev = dev
_LOGGER.info( _LOGGER.info(
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)", "✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
self._vendor_id, pid, dev.bus, dev.address self._vendor_id, pid, dev.bus, dev.address
) )
# Ждём 500ms после подключения для стабилизации устройства
import time
time.sleep(0.5)
return return
else: else:
_LOGGER.debug("Device %04x:%04x not found", self._vendor_id, pid) _LOGGER.debug("Device %04x:%04x not found", self._vendor_id, pid)