Compare commits
50
Commits
master
...
satellite-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cc8bb8aef | ||
|
|
8a4686c800 | ||
|
|
a31fb3a112 | ||
|
|
26d09501fb | ||
|
|
2b4e6f824b | ||
|
|
61294485ad | ||
|
|
03748505cd | ||
|
|
addc15eecc | ||
|
|
9bfbcfd8a8 | ||
|
|
c17743d829 | ||
|
|
6b9c7376b9 | ||
|
|
e9b6d7bb06 | ||
|
|
4a79874807 | ||
|
|
6913625830 | ||
|
|
023e283d3f | ||
|
|
fafc39511b | ||
|
|
40382afac5 | ||
|
|
3d9ebfbb0a | ||
|
|
f9a3731a98 | ||
|
|
168ec73b7c | ||
|
|
fc816ed7b3 | ||
|
|
ed6ecc548b | ||
|
|
0776d839b9 | ||
|
|
c7bf061611 | ||
|
|
4e5b961f7b | ||
|
|
9d8e78fe2a | ||
|
|
d8ab4736de | ||
|
|
b794f4cafb | ||
|
|
27703a2f93 | ||
|
|
b1f7185a4f | ||
|
|
99fa55e15e | ||
|
|
5a5cac90b1 | ||
|
|
ba006c506b | ||
|
|
12d5d14d40 | ||
|
|
d9868a0c5b | ||
|
|
b394a2fe74 | ||
|
|
b27d633f06 | ||
|
|
e5914b23b3 | ||
|
|
26932097a5 | ||
|
|
b28052e93e | ||
|
|
5c92eed55b | ||
|
|
6398dc6a41 | ||
|
|
b1c6ef3385 | ||
|
|
6e06120b55 | ||
|
|
5d59246902 | ||
|
|
f62b0163d0 | ||
|
|
235fcbece7 | ||
|
|
c027567725 | ||
|
|
0f66ba1b54 | ||
|
|
c259a8c512 |
@@ -1,9 +0,0 @@
|
||||
*
|
||||
!pyproject.toml
|
||||
!script/setup
|
||||
!script/run
|
||||
!wyoming_satellite/*.py
|
||||
!wyoming_satellite/utils
|
||||
!wyoming_satellite/VERSION
|
||||
!docker/run
|
||||
!sounds/*.wav
|
||||
@@ -1,2 +0,0 @@
|
||||
- /.venv/
|
||||
- /.mypy_cache/
|
||||
-23
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -169,3 +169,106 @@ ExecStart=satellite-python -m wyoming_satellite ...
|
||||
|
||||
- [Руководство по развёртыванию](../../DEPLOY.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
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
**Конец документа**
|
||||
@@ -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
|
||||
@@ -137,12 +137,10 @@ class XVF3800LED:
|
||||
_LOGGER.debug("Looking for PID %04x...", pid)
|
||||
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=pid)
|
||||
if dev is not None:
|
||||
try:
|
||||
if dev.is_kernel_driver_active(0):
|
||||
_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)
|
||||
# НЕ делаем detach_kernel_driver() — это отключает audio интерфейсы!
|
||||
# Control transfer с CTRL_TYPE_VENDOR работает без claim interface
|
||||
# Так работает xvf_host.py — там тоже нет detach_kernel_driver()
|
||||
|
||||
self._dev = dev
|
||||
_LOGGER.info(
|
||||
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
|
||||
|
||||
@@ -1,103 +1,491 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wyoming Satellite WebSocket</title>
|
||||
<title>Wyoming Satellite Control</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 20px;
|
||||
background: #1a1a2e;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 { color: #00d9ff; margin-bottom: 10px; }
|
||||
#status {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin-bottom: 15px;
|
||||
font-weight: bold;
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
#status.connected { background: #2d5a3d; color: #7fff7f; }
|
||||
#status.disconnected { background: #5a2d2d; color: #ff7f7f; }
|
||||
#status.connecting { background: #5a5a2d; color: #ffff7f; }
|
||||
#log {
|
||||
background: #0f0f1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
height: calc(100vh - 180px);
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.event {
|
||||
border-left: 3px solid #00d9ff;
|
||||
padding-left: 10px;
|
||||
margin: 10px 0;
|
||||
background: rgba(0, 217, 255, 0.05);
|
||||
padding: 10px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
background: linear-gradient(90deg, #00d9ff, #00ff88);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.event-type { color: #00d9ff; font-weight: bold; }
|
||||
.event-data { color: #aaa; }
|
||||
.clear-btn {
|
||||
background: #00d9ff;
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
|
||||
.status {
|
||||
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;
|
||||
background: linear-gradient(90deg, #00d9ff 0%, #00ff88 100%);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🛰️ Wyoming Satellite WebSocket</h1>
|
||||
<div id="status" class="connecting">Connecting...</div>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🛰️ Wyoming Satellite Control</h1>
|
||||
<div style="color: #aaa; font-size: 14px;">WebSocket Client & Audio Level Monitor</div>
|
||||
</div>
|
||||
<div class="status" id="status">
|
||||
Connecting...
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button class="clear-btn" onclick="toggleSettings()">⚙️ Settings</button>
|
||||
<button class="clear-btn" onclick="clearLog()">Clear Log</button>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="settings-panel" id="settingsPanel">
|
||||
<label style="color: #00d9ff; font-weight: 600; display: block; margin-bottom: 8px;">
|
||||
WebSocket URL:
|
||||
</label>
|
||||
<input type="text" id="wsUrl" value="ws://localhost:8675">
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div id="settings" style="display:none; background:#0f0f1a; padding:15px; border-radius:4px; margin-bottom:15px;">
|
||||
<label for="wsUrl" style="color:#00d9ff; font-weight:bold;">WebSocket URL:</label>
|
||||
<input type="text" id="wsUrl" value="ws://localhost:8675"
|
||||
style="width:100%; padding:8px; margin:5px 0; background:#1a1a2e; color:#eee; border:1px solid #333; border-radius:4px;">
|
||||
<button class="clear-btn" onclick="connect()">Connect</button>
|
||||
<label style="margin-left:10px; color:#aaa;">
|
||||
<input type="checkbox" id="autoReconnect" checked> Auto-reconnect
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
// State
|
||||
let ws = null;
|
||||
let eventCount = 0;
|
||||
let currentRms = 0;
|
||||
let rmsTimeout = null;
|
||||
|
||||
// Elements
|
||||
const log = document.getElementById('log');
|
||||
const status = document.getElementById('status');
|
||||
const settingsPanel = document.getElementById('settings');
|
||||
const settingsPanel = document.getElementById('settingsPanel');
|
||||
const wsUrlInput = document.getElementById('wsUrl');
|
||||
const autoReconnectCheckbox = document.getElementById('autoReconnect');
|
||||
let eventCount = 0;
|
||||
let ws = null;
|
||||
let autoReconnect = true;
|
||||
const rmsFill = document.getElementById('rmsFill');
|
||||
const rmsValue = document.getElementById('rmsValue');
|
||||
|
||||
function toggleSettings() {
|
||||
settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
// Auto-detect WebSocket URL
|
||||
window.addEventListener('load', () => {
|
||||
const detectedUrl = `ws://${window.location.hostname}:8675`;
|
||||
wsUrlInput.value = detectedUrl;
|
||||
connect();
|
||||
});
|
||||
|
||||
// Connect to WebSocket
|
||||
function connect() {
|
||||
const wsUrl = wsUrlInput.value.trim();
|
||||
|
||||
if (!wsUrl) {
|
||||
addEvent('SYSTEM', { error: 'Please enter a WebSocket URL' });
|
||||
return;
|
||||
@@ -108,26 +496,32 @@
|
||||
ws.close();
|
||||
}
|
||||
|
||||
setStatus('connecting', 'Connecting...');
|
||||
addEvent('SYSTEM', { status: `Connecting to ${wsUrl}...` });
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
status.textContent = 'Connected to ' + wsUrl;
|
||||
status.className = 'connected';
|
||||
addEvent('SYSTEM', { status: 'Connected to ' + wsUrl });
|
||||
setStatus('connected', `Connected`);
|
||||
addEvent('SYSTEM', { status: `Connected to ${wsUrl}` });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.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) {
|
||||
addEvent('RAW', { raw: event.data });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
status.textContent = 'Disconnected';
|
||||
status.className = 'disconnected';
|
||||
setStatus('disconnected', 'Disconnected');
|
||||
addEvent('SYSTEM', { status: 'Connection closed' });
|
||||
|
||||
// Auto-reconnect
|
||||
@@ -140,39 +534,117 @@
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
status.textContent = 'Error';
|
||||
status.className = 'disconnected';
|
||||
setStatus('disconnected', 'Error');
|
||||
addEvent('SYSTEM', { error: 'WebSocket error', details: err.toString() });
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-detect WebSocket URL from current page location on first load
|
||||
window.addEventListener('load', () => {
|
||||
const detectedUrl = `ws://${window.location.hostname}:8675`;
|
||||
wsUrlInput.value = detectedUrl;
|
||||
connect();
|
||||
// Set status
|
||||
function setStatus(state, text) {
|
||||
status.className = `status ${state}`;
|
||||
status.textContent = text;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
eventCount++;
|
||||
const time = new Date().toLocaleTimeString();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'event';
|
||||
div.innerHTML = `<span class="event-type">[${time}] #${eventCount} ${escapeHtml(type)}</span>\n<span class="event-data">${escapeHtml(JSON.stringify(data, null, 2))}</span>`;
|
||||
div.className = `log-entry ${type.toLowerCase().replace('_', '-')}`;
|
||||
|
||||
let dataStr = '';
|
||||
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);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
|
||||
// Auto-scroll
|
||||
if (document.getElementById('autoScroll').checked) {
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear log
|
||||
function clearLog() {
|
||||
log.innerHTML = '';
|
||||
eventCount = 0;
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
log.innerHTML = '';
|
||||
eventCount = 0;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -165,13 +165,12 @@ def set_led_state(state: str, skip_publish=False):
|
||||
logger.info(f"🟢 Speaking: зелёный ({brightness})")
|
||||
|
||||
elif state == "idle":
|
||||
# DoA эффект: яркость управляется через значения цвета
|
||||
base_rgb = scale_color((0x30, 0x30, 0x30), brightness)
|
||||
doa_rgb = scale_color((0x00, 0xBC, 0xD4), brightness)
|
||||
base_color = rgb_to_bgr_int(*base_rgb)
|
||||
doa_color = rgb_to_bgr_int(*doa_rgb)
|
||||
# DoA эффект: яркость управляется отдельно через set_led_brightness
|
||||
base_color = rgb_to_bgr_int(0x30, 0x30, 0x30) # 0x303030 - серый
|
||||
doa_color = rgb_to_bgr_int(0x00, 0xBC, 0xD4) # 0xD4BC00 - бирюзовый (BGR)
|
||||
led_controller.set_led_effect(EFFECT_DOA)
|
||||
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})")
|
||||
|
||||
elif state == "paused":
|
||||
|
||||
Executable
+102
@@ -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 ""
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
docker build . \
|
||||
-t 'rhasspy/wyoming-satellite' "$@"
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
docker run \
|
||||
-it \
|
||||
-p 10700:10700 \
|
||||
--device /dev/snd \
|
||||
'rhasspy/wyoming-satellite' "$@"
|
||||
Executable
+79
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -5,22 +5,13 @@ set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
export PYTHONPATH="${SCRIPT_DIR}"
|
||||
|
||||
# Путь к Python-окружению (ищем в стандартных местах)
|
||||
VENV_PATH=""
|
||||
for candidate in \
|
||||
"${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
|
||||
# Путь к Python-окружению
|
||||
# Приоритет: .venv/ в корне проекта (локальное окружение)
|
||||
VENV_PATH="${SCRIPT_DIR}/.venv"
|
||||
|
||||
if [ -z "$VENV_PATH" ]; then
|
||||
echo "❌ Виртуальное окружение не найдено!" >&2
|
||||
if [ ! -f "${VENV_PATH}/bin/python3" ]; then
|
||||
echo "❌ Виртуальное окружение не найдено: ${VENV_PATH}" >&2
|
||||
echo "💡 Создайте: python3 -m venv ${VENV_PATH} && source ${VENV_PATH}/bin/activate && pip install -r requirements.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -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.
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
Executable
+163
@@ -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()
|
||||
@@ -257,6 +257,37 @@ async def main() -> None:
|
||||
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
|
||||
parser.add_argument(
|
||||
"--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),
|
||||
connected=split_command(args.connected_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(
|
||||
started=split_command(args.timer_started_command),
|
||||
|
||||
+415
-19
@@ -2,6 +2,7 @@
|
||||
|
||||
import array
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
@@ -49,8 +50,19 @@ from .utils import (
|
||||
from .vad import SileroVad, WebRtcVadEnergy
|
||||
from .webrtc import WebRtcAudio
|
||||
|
||||
try:
|
||||
import websockets
|
||||
WEBSOCKETS_AVAILABLE = True
|
||||
except ImportError:
|
||||
websockets = None # type: ignore
|
||||
WEBSOCKETS_AVAILABLE = False
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
|
||||
# Уменьшаем спам в логах от websockets библиотеки
|
||||
# Показываем только важные сообщения, кроме audio-level
|
||||
logging.getLogger('websockets.server').setLevel(logging.WARNING)
|
||||
|
||||
_PONG_TIMEOUT: Final = 5
|
||||
_PING_SEND_DELAY: Final = 2
|
||||
_WAKE_INFO_TIMEOUT: Final = 2
|
||||
@@ -100,6 +112,25 @@ class SatelliteBase:
|
||||
self.microphone_muted = False
|
||||
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
|
||||
self.wake_audio_writer: Optional[DebugAudioWriter] = None
|
||||
self.stt_audio_writer: Optional[DebugAudioWriter] = None
|
||||
@@ -234,6 +265,17 @@ class SatelliteBase:
|
||||
"""Connect to services."""
|
||||
self.state = State.STARTING
|
||||
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
|
||||
await self.started()
|
||||
|
||||
@@ -245,6 +287,15 @@ class SatelliteBase:
|
||||
self.server_id = 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()
|
||||
self._disable_ping()
|
||||
self.state = State.STOPPED
|
||||
@@ -546,6 +597,10 @@ class SatelliteBase:
|
||||
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:
|
||||
# Noise suppression and auto gain
|
||||
audio_bytes = self._mic_webrtc(audio_bytes)
|
||||
@@ -604,9 +659,13 @@ class SatelliteBase:
|
||||
_LOGGER.debug("Connected to snd service")
|
||||
|
||||
# Audio processing
|
||||
if self.settings.snd.needs_processing and AudioChunk.is_type(
|
||||
event.type
|
||||
):
|
||||
# Проверяем нужна ли обработка: базовый volume_multiplier или runtime громкость
|
||||
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)
|
||||
audio_bytes = self._process_snd_audio(chunk.audio)
|
||||
event = AudioChunk(
|
||||
@@ -644,6 +703,11 @@ class SatelliteBase:
|
||||
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
|
||||
|
||||
async def _play_wav(
|
||||
@@ -916,10 +980,20 @@ class SatelliteBase:
|
||||
await asyncio.sleep(self.settings.timer.finished_wav_delay)
|
||||
|
||||
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:
|
||||
self._event_queue.put_nowait(event)
|
||||
|
||||
# WebSocket клиенты
|
||||
await self._broadcast_to_websocket(event)
|
||||
|
||||
def _make_event_client(self) -> Optional[AsyncClient]:
|
||||
"""Create client for event service."""
|
||||
if self.settings.event.uri:
|
||||
@@ -951,11 +1025,21 @@ class SatelliteBase:
|
||||
await event_client.connect()
|
||||
_LOGGER.debug("Connected to event service")
|
||||
|
||||
await event_client.write_event(event)
|
||||
try:
|
||||
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:
|
||||
break
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error in event read task")
|
||||
_LOGGER.exception("Unexpected error in event task")
|
||||
await _disconnect()
|
||||
event_client = None # reconnect
|
||||
self._event_queue = None
|
||||
@@ -963,6 +1047,306 @@ class SatelliteBase:
|
||||
|
||||
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
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -1022,15 +1406,18 @@ class AlwaysStreamingSatellite(SatelliteBase):
|
||||
return
|
||||
|
||||
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
|
||||
await self.event_to_server(event)
|
||||
|
||||
# Debug audio recording
|
||||
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)
|
||||
|
||||
|
||||
@@ -1114,13 +1501,15 @@ class VadStreamingSatellite(SatelliteBase):
|
||||
# Only unpack chunk once
|
||||
chunk: Optional[AudioChunk] = None
|
||||
|
||||
# Вычислить RMS для аудио чанка
|
||||
if audio_bytes is None:
|
||||
chunk = AudioChunk.from_event(event)
|
||||
audio_bytes = chunk.audio
|
||||
|
||||
self._last_mic_level = self._calculate_rms(audio_bytes)
|
||||
|
||||
# Debug audio recording
|
||||
if self.stt_audio_writer is not None:
|
||||
if audio_bytes is None:
|
||||
# Need to unpack
|
||||
chunk = AudioChunk.from_event(event)
|
||||
audio_bytes = chunk.audio
|
||||
|
||||
self.stt_audio_writer.write(audio_bytes)
|
||||
|
||||
if (
|
||||
@@ -1320,12 +1709,19 @@ class WakeStreamingSatellite(SatelliteBase):
|
||||
):
|
||||
return
|
||||
|
||||
# Вычислить RMS для аудио чанка
|
||||
if audio_bytes is None:
|
||||
chunk = AudioChunk.from_event(event)
|
||||
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 audio_bytes is None:
|
||||
chunk = AudioChunk.from_event(event)
|
||||
audio_bytes = chunk.audio
|
||||
|
||||
if self.wake_audio_writer is not None:
|
||||
self.wake_audio_writer.write(audio_bytes)
|
||||
|
||||
|
||||
@@ -178,6 +178,23 @@ class EventSettings(ServiceSettings):
|
||||
connected: 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)
|
||||
class TimerSettings:
|
||||
|
||||
@@ -38,6 +38,7 @@ USB_VENDOR_ID = 0x2886
|
||||
USB_PRODUCT_IDS = [0x0018, 0x001A]
|
||||
|
||||
# GPO_SERVICER_RESID LED commands
|
||||
# resid=20 (GPO service), cmdid=12-19 (LED commands)
|
||||
GPO_RESID = 20
|
||||
LED_EFFECT_CMD = 12
|
||||
LED_BRIGHTNESS_CMD = 13
|
||||
@@ -152,18 +153,19 @@ class XVF3800Controller:
|
||||
_LOGGER.debug("Looking for PID %04x...", pid)
|
||||
dev = usb.core.find(idVendor=self._vendor_id, idProduct=pid)
|
||||
if dev is not None:
|
||||
try:
|
||||
if dev.is_kernel_driver_active(0):
|
||||
_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)
|
||||
# НЕ делаем detach_kernel_driver() — это отключает audio интерфейсы!
|
||||
# Control transfer с CTRL_TYPE_VENDOR работает без claim interface
|
||||
|
||||
self._dev = dev
|
||||
_LOGGER.info(
|
||||
"✅ XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
|
||||
self._vendor_id, pid, dev.bus, dev.address
|
||||
)
|
||||
|
||||
# Ждём 500ms после подключения для стабилизации устройства
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
|
||||
return
|
||||
else:
|
||||
_LOGGER.debug("Device %04x:%04x not found", self._vendor_id, pid)
|
||||
|
||||
Reference in New Issue
Block a user