Полная синхронизация
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
# Развёртывание Wyoming Satellite
|
||||||
|
|
||||||
|
## Быстрый старт на новом сервере
|
||||||
|
|
||||||
|
### 1. Клонирование репозитория
|
||||||
|
```bash
|
||||||
|
git clone <repository-url> /path/to/Satellite
|
||||||
|
cd Satellite
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Создание виртуального окружения
|
||||||
|
```bash
|
||||||
|
# Вариант A: в домашней директории (рекомендуется)
|
||||||
|
python3 -m venv ~/.venv_satellite
|
||||||
|
source ~/.venv_satellite/bin/activate
|
||||||
|
|
||||||
|
# Вариант B: в projects
|
||||||
|
python3 -m venv ~/projects/.venv_satellite
|
||||||
|
source ~/projects/.venv_satellite/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Установка зависимостей
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Установка systemd сервисов
|
||||||
|
```bash
|
||||||
|
sudo ./install_systemd_services.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт автоматически:
|
||||||
|
- Найдёт виртуальное окружение
|
||||||
|
- Определит текущего пользователя
|
||||||
|
- Подставит правильные пути в service файлы
|
||||||
|
- Установит wrapper `satellite-python` в `/usr/local/bin/`
|
||||||
|
|
||||||
|
### 5. Запуск сервисов
|
||||||
|
```bash
|
||||||
|
# LED сервис (опционально)
|
||||||
|
sudo systemctl enable wyoming-event.service
|
||||||
|
sudo systemctl start wyoming-event.service
|
||||||
|
|
||||||
|
# Wyoming Satellite
|
||||||
|
sudo systemctl enable wyoming-satelite-vosk.service
|
||||||
|
sudo systemctl start wyoming-satelite-vosk.service
|
||||||
|
|
||||||
|
# Wake Word сервис
|
||||||
|
sudo systemctl enable wyoming-wakeword-vosk.service
|
||||||
|
sudo systemctl start wyoming-wakeword-vosk.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Проверка статуса
|
||||||
|
```bash
|
||||||
|
sudo systemctl status wyoming-event.service
|
||||||
|
journalctl -u wyoming-event.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура service файлов
|
||||||
|
|
||||||
|
Все service файлы используют плейсхолдеры, которые заменяются при установке:
|
||||||
|
|
||||||
|
| Плейсхолдер | Описание |
|
||||||
|
|-------------|----------|
|
||||||
|
| `__SAT_BASE_DIR__` | Путь к репозиторию Satellite |
|
||||||
|
| `__SAT_VENV__` | Путь к виртуальному окружению |
|
||||||
|
| `__CURRENT_USER__` | Имя пользователя |
|
||||||
|
| `__HOME_DIR__` | Домашняя директория пользователя |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ручная установка (альтернатива)
|
||||||
|
|
||||||
|
Если скрипт не работает, можно вручную:
|
||||||
|
|
||||||
|
1. Отредактировать файлы в `service/`, заменив плейсхолдеры
|
||||||
|
2. Скопировать в `/etc/systemd/system/`
|
||||||
|
3. Выполнить `sudo systemctl daemon-reload`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wrapper `satellite-python`
|
||||||
|
|
||||||
|
Глобальный скрипт `/usr/local/bin/satellite-python` позволяет запускать Python из venv без указания полного пути:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# В systemd сервисе
|
||||||
|
ExecStart=satellite-python /path/to/script.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Это избавляет от необходимости прописывать пути к `.venv` в каждом сервисе.
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ReSpeaker XVF3800 USB 4-MIC Array – Terminal LED Controller.
|
||||||
|
|
||||||
|
Использует встроенные эффекты XVF3800:
|
||||||
|
0 = off
|
||||||
|
1 = breath (дыхание)
|
||||||
|
2 = rainbow (радуга)
|
||||||
|
3 = single color (один цвет)
|
||||||
|
4 = doa (направление звука)
|
||||||
|
5 = ring (кольцо, может не поддерживаться)
|
||||||
|
|
||||||
|
Ввод цифр 0-10 выбирает анимацию, q - выход.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import usb.core
|
||||||
|
import usb.util
|
||||||
|
except ImportError:
|
||||||
|
print("❌ pyusb not installed. Install with: pip install pyusb")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Configuration
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
USB_VENDOR_ID = 0x2886
|
||||||
|
USB_PRODUCT_IDS = [0x0018, 0x001A]
|
||||||
|
|
||||||
|
# GPO_SERVICER_RESID LED commands
|
||||||
|
GPO_RESID = 20
|
||||||
|
LED_EFFECT = 12
|
||||||
|
LED_BRIGHTNESS = 13
|
||||||
|
LED_SPEED = 15
|
||||||
|
LED_COLOR = 16
|
||||||
|
LED_DOA_COLOR = 17
|
||||||
|
|
||||||
|
# Effects
|
||||||
|
EFFECT_OFF = 0
|
||||||
|
EFFECT_BREATH = 1
|
||||||
|
EFFECT_RAINBOW = 2
|
||||||
|
EFFECT_SINGLE = 3
|
||||||
|
EFFECT_DOA = 4
|
||||||
|
EFFECT_RING = 5
|
||||||
|
|
||||||
|
# Colors (0xBBGGRR format - BGR byte order!)
|
||||||
|
COLOR_CYAN = 0xC0BB00
|
||||||
|
COLOR_RED = 0x0000FF
|
||||||
|
COLOR_GREEN = 0x00FF00
|
||||||
|
COLOR_BLUE = 0xFF0000
|
||||||
|
COLOR_YELLOW = 0x00FFFF
|
||||||
|
COLOR_MAGENTA = 0xFF00FF
|
||||||
|
COLOR_WHITE = 0xFFFFFF
|
||||||
|
COLOR_OFF = 0x000000
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger("xvf3800-led")
|
||||||
|
|
||||||
|
|
||||||
|
class XVF3800LED:
|
||||||
|
"""Controls LEDs on XVF3800 via USB control transfers."""
|
||||||
|
|
||||||
|
TIMEOUT = 1000
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._dev = None
|
||||||
|
self._dev_lock = threading.Lock()
|
||||||
|
self._find_device()
|
||||||
|
|
||||||
|
def _find_device(self) -> None:
|
||||||
|
for pid in USB_PRODUCT_IDS:
|
||||||
|
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=pid)
|
||||||
|
if dev is not None:
|
||||||
|
try:
|
||||||
|
if dev.is_kernel_driver_active(0):
|
||||||
|
dev.detach_kernel_driver(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._dev = dev
|
||||||
|
_LOGGER.info(
|
||||||
|
"XVF3800 found (USB %04x:%04x, bus %d, addr %d)",
|
||||||
|
USB_VENDOR_ID, pid, dev.bus, dev.address,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_LOGGER.error(
|
||||||
|
"XVF3800 not found (USB %04x). Check connection and udev rules.",
|
||||||
|
USB_VENDOR_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ctrl_transfer(self, resid: int, cmdid: int, payload: list) -> bool:
|
||||||
|
with self._dev_lock:
|
||||||
|
if self._dev is None:
|
||||||
|
_LOGGER.warning("USB device not available")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("CTRL: resid=%d, cmdid=%d, payload=%s", resid, cmdid, payload)
|
||||||
|
self._dev.ctrl_transfer(
|
||||||
|
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
||||||
|
0, cmdid, resid, payload, self.TIMEOUT,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except usb.core.USBError as exc:
|
||||||
|
_LOGGER.warning("USB write error: %s", exc)
|
||||||
|
self._dev = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_effect(self, effect: int) -> bool:
|
||||||
|
return self._ctrl_transfer(GPO_RESID, LED_EFFECT, [effect])
|
||||||
|
|
||||||
|
def set_brightness(self, brightness: int) -> bool:
|
||||||
|
brightness = max(0, min(255, brightness))
|
||||||
|
return self._ctrl_transfer(GPO_RESID, LED_BRIGHTNESS, [brightness])
|
||||||
|
|
||||||
|
def set_speed(self, speed: int) -> bool:
|
||||||
|
speed = max(0, min(255, speed))
|
||||||
|
return self._ctrl_transfer(GPO_RESID, LED_SPEED, [speed])
|
||||||
|
|
||||||
|
def set_color(self, color: int) -> bool:
|
||||||
|
payload = list(struct.pack(b'<I', color))
|
||||||
|
return self._ctrl_transfer(GPO_RESID, LED_COLOR, payload)
|
||||||
|
|
||||||
|
def set_doa_color(self, base_color: int, doa_color: int) -> bool:
|
||||||
|
payload = list(struct.pack(b'<II', base_color, doa_color))
|
||||||
|
return self._ctrl_transfer(GPO_RESID, LED_DOA_COLOR, payload)
|
||||||
|
|
||||||
|
def off(self) -> None:
|
||||||
|
self.set_effect(EFFECT_OFF)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.off()
|
||||||
|
with self._dev_lock:
|
||||||
|
if self._dev is not None:
|
||||||
|
usb.util.dispose_resources(self._dev)
|
||||||
|
self._dev = None
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# LED ring animator - uses built-in XVF3800 effects
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class LEDRing:
|
||||||
|
"""
|
||||||
|
Manages 12-LED ring animations using built-in XVF3800 effects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ANIM_OFF = "off"
|
||||||
|
ANIM_TWINKLE = "twinkle" # breath red
|
||||||
|
ANIM_TWINKLE_BLUE = "twinkle_blue" # breath blue
|
||||||
|
ANIM_WAIT_CMD = "waiting" # rainbow slow
|
||||||
|
ANIM_LISTENING = "listening" # rainbow fast
|
||||||
|
ANIM_THINKING = "thinking" # breath cyan
|
||||||
|
ANIM_REPLYING = "replying" # rainbow fast reverse (same as listening)
|
||||||
|
ANIM_MUTED = "muted" # single red
|
||||||
|
ANIM_ERROR = "error" # breath magenta fast
|
||||||
|
ANIM_TIMER_TICK = "timer_tick" # doa mode
|
||||||
|
ANIM_TIMER_RING = "timer_ring" # breath fast
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._leds = XVF3800LED()
|
||||||
|
self._animation = self.ANIM_OFF
|
||||||
|
self._anim_lock = threading.Lock()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._loop, daemon=True, name="led-ring"
|
||||||
|
)
|
||||||
|
self._index = 0
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
self._leds.off()
|
||||||
|
self._leds.close()
|
||||||
|
|
||||||
|
def set_animation(self, name: str) -> None:
|
||||||
|
with self._anim_lock:
|
||||||
|
if self._animation != name:
|
||||||
|
_LOGGER.info("LED animation → %s", name)
|
||||||
|
self._animation = name
|
||||||
|
self._index = 0
|
||||||
|
|
||||||
|
def _apply_animation(self) -> float:
|
||||||
|
anim = self._animation
|
||||||
|
|
||||||
|
if anim == self.ANIM_OFF:
|
||||||
|
self._leds.set_effect(EFFECT_OFF)
|
||||||
|
return 0.1
|
||||||
|
|
||||||
|
elif anim == self.ANIM_TWINKLE:
|
||||||
|
# Breath effect with red color
|
||||||
|
self._leds.set_effect(EFFECT_BREATH)
|
||||||
|
self._leds.set_color(COLOR_RED)
|
||||||
|
self._leds.set_brightness(150)
|
||||||
|
self._leds.set_speed(2)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_TWINKLE_BLUE:
|
||||||
|
# Breath effect with blue color
|
||||||
|
self._leds.set_effect(EFFECT_BREATH)
|
||||||
|
self._leds.set_color(COLOR_BLUE)
|
||||||
|
self._leds.set_brightness(150)
|
||||||
|
self._leds.set_speed(2)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_WAIT_CMD:
|
||||||
|
# Rainbow slow
|
||||||
|
self._leds.set_effect(EFFECT_RAINBOW)
|
||||||
|
self._leds.set_brightness(150)
|
||||||
|
self._leds.set_speed(1)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_LISTENING:
|
||||||
|
# Rainbow fast
|
||||||
|
self._leds.set_effect(EFFECT_RAINBOW)
|
||||||
|
self._leds.set_brightness(150)
|
||||||
|
self._leds.set_speed(4)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_THINKING:
|
||||||
|
# Breath cyan
|
||||||
|
self._leds.set_effect(EFFECT_BREATH)
|
||||||
|
self._leds.set_color(COLOR_CYAN)
|
||||||
|
self._leds.set_brightness(150)
|
||||||
|
self._leds.set_speed(3)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_REPLYING:
|
||||||
|
# Rainbow fast (same as listening - no reverse on XVF3800)
|
||||||
|
self._leds.set_effect(EFFECT_RAINBOW)
|
||||||
|
self._leds.set_brightness(150)
|
||||||
|
self._leds.set_speed(4)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_MUTED:
|
||||||
|
# Solid red
|
||||||
|
self._leds.set_effect(EFFECT_SINGLE)
|
||||||
|
self._leds.set_color(COLOR_RED)
|
||||||
|
self._leds.set_brightness(200)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_ERROR:
|
||||||
|
# Breath magenta fast
|
||||||
|
self._leds.set_effect(EFFECT_BREATH)
|
||||||
|
self._leds.set_color(COLOR_MAGENTA)
|
||||||
|
self._leds.set_brightness(200)
|
||||||
|
self._leds.set_speed(5)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_TIMER_TICK:
|
||||||
|
# DoA mode - shows direction
|
||||||
|
self._leds.set_effect(EFFECT_DOA)
|
||||||
|
self._leds.set_doa_color(0x303030, COLOR_CYAN)
|
||||||
|
self._leds.set_brightness(150)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
elif anim == self.ANIM_TIMER_RING:
|
||||||
|
# Breath fast
|
||||||
|
self._leds.set_effect(EFFECT_BREATH)
|
||||||
|
self._leds.set_color(COLOR_YELLOW)
|
||||||
|
self._leds.set_brightness(200)
|
||||||
|
self._leds.set_speed(5)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
def _loop(self) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
with self._anim_lock:
|
||||||
|
anim = self._animation
|
||||||
|
sleep = self._apply_animation()
|
||||||
|
time.sleep(sleep)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Main
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
ANIM_MAP = {
|
||||||
|
0: LEDRing.ANIM_OFF,
|
||||||
|
1: LEDRing.ANIM_TWINKLE,
|
||||||
|
2: LEDRing.ANIM_TWINKLE_BLUE,
|
||||||
|
3: LEDRing.ANIM_WAIT_CMD,
|
||||||
|
4: LEDRing.ANIM_LISTENING,
|
||||||
|
5: LEDRing.ANIM_THINKING,
|
||||||
|
6: LEDRing.ANIM_REPLYING,
|
||||||
|
7: LEDRing.ANIM_MUTED,
|
||||||
|
8: LEDRing.ANIM_ERROR,
|
||||||
|
9: LEDRing.ANIM_TIMER_TICK,
|
||||||
|
10: LEDRing.ANIM_TIMER_RING,
|
||||||
|
}
|
||||||
|
|
||||||
|
ANIM_NAMES = {
|
||||||
|
0: "off - выключить",
|
||||||
|
1: "twinkle - красное дыхание",
|
||||||
|
2: "twinkle_blue - синее дыхание",
|
||||||
|
3: "waiting - радуга (медленно)",
|
||||||
|
4: "listening - радуга (быстро)",
|
||||||
|
5: "thinking - циан дыхание",
|
||||||
|
6: "replying - радуга (быстро)",
|
||||||
|
7: "muted - красный",
|
||||||
|
8: "error - маджента быстрое",
|
||||||
|
9: "timer_tick - DoA режим",
|
||||||
|
10: "timer_ring - жёлтое быстрое",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def print_menu() -> None:
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(" ReSpeaker XVF3800 LED Controller")
|
||||||
|
print(" (встроенные эффекты)")
|
||||||
|
print("=" * 50)
|
||||||
|
for idx, name in ANIM_NAMES.items():
|
||||||
|
print(f" {idx:2d} - {name}")
|
||||||
|
print(" q - Quit")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
def run_terminal_controller() -> None:
|
||||||
|
leds = LEDRing()
|
||||||
|
leds.start()
|
||||||
|
|
||||||
|
def _shutdown(signum, frame) -> None:
|
||||||
|
print("\n\nShutting down...")
|
||||||
|
leds.stop()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
|
||||||
|
print_menu()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = input("\nSelect animation (0-10, q to quit): ").strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
|
||||||
|
if user_input == "q":
|
||||||
|
print("Exiting...")
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
anim_idx = int(user_input)
|
||||||
|
if anim_idx not in ANIM_MAP:
|
||||||
|
print(f"❌ Invalid selection: {anim_idx}. Choose 0-10 or q.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
anim_name = ANIM_MAP[anim_idx]
|
||||||
|
leds.set_animation(anim_name)
|
||||||
|
print(f"✅ Selected: {anim_idx} - {ANIM_NAMES[anim_idx]}")
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
print(f"❌ Invalid input: '{user_input}'. Choose 0-10 or q.")
|
||||||
|
|
||||||
|
leds.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="ReSpeaker XVF3800 USB 4-MIC Array LED Controller"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug", action="store_true",
|
||||||
|
help="Enable DEBUG logging",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if args.debug else logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
run_terminal_controller()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
"idle": 80,
|
"idle": 80,
|
||||||
"paused": 40,
|
"paused": 40,
|
||||||
"error": 255,
|
"error": 255,
|
||||||
"global_multiplier": 0.6392156862745098
|
"global_multiplier": 0.3
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Wyoming Satellite WebSocket</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
padding: 20px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h1 { color: #00d9ff; margin-bottom: 10px; }
|
||||||
|
#status {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.event-type { color: #00d9ff; font-weight: bold; }
|
||||||
|
.event-data { color: #aaa; }
|
||||||
|
.clear-btn {
|
||||||
|
background: #00d9ff;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.clear-btn:hover { background: #00b8d9; }
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>🛰️ Wyoming Satellite WebSocket</h1>
|
||||||
|
<div id="status" class="connecting">Connecting...</div>
|
||||||
|
</div>
|
||||||
|
<button class="clear-btn" onclick="clearLog()">Clear Log</button>
|
||||||
|
</div>
|
||||||
|
<div id="log"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const log = document.getElementById('log');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
let eventCount = 0;
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
log.appendChild(div);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
log.innerHTML = '';
|
||||||
|
eventCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect WebSocket host from current page location
|
||||||
|
const wsHost = window.location.hostname;
|
||||||
|
const wsPort = 8675;
|
||||||
|
const wsUrl = `ws://${wsHost}:${wsPort}`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
status.textContent = 'Connected';
|
||||||
|
status.className = 'connected';
|
||||||
|
addEvent('SYSTEM', { status: 'Connected to ws://localhost:8675' });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
addEvent(data.type, data.data);
|
||||||
|
} catch (e) {
|
||||||
|
addEvent('RAW', { raw: event.data });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
status.textContent = 'Disconnected';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
addEvent('SYSTEM', { status: 'Connection closed' });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
status.textContent = 'Error';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
addEvent('SYSTEM', { error: 'WebSocket error', details: err.toString() });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import asyncio
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
async with websockets.connect("ws://localhost:8675") as ws:
|
||||||
|
print("Connected!")
|
||||||
|
while True:
|
||||||
|
msg = await ws.recv()
|
||||||
|
print(f"Received: {msg}")
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
+191
-100
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Wyoming LED Control - Простая надёжная версия для HA
|
Wyoming LED Control with MQTT Discovery for Home Assistant
|
||||||
|
Полная версия: Light + Sensor + 7 Number sliders
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -41,8 +42,8 @@ MQTT_PASS = "mqtt"
|
|||||||
# Топики
|
# Топики
|
||||||
MQTT_TOPIC_COMMAND = "wyoming/led/command"
|
MQTT_TOPIC_COMMAND = "wyoming/led/command"
|
||||||
MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set"
|
MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set"
|
||||||
MQTT_TOPIC_STATE = "wyoming/led/state" # ON/OFF
|
MQTT_TOPIC_STATE = "wyoming/led/state"
|
||||||
MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state" # яркость
|
MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state"
|
||||||
MQTT_TOPIC_SATELLITE_STATE = "wyoming/satellite/state"
|
MQTT_TOPIC_SATELLITE_STATE = "wyoming/satellite/state"
|
||||||
MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability"
|
MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability"
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ MQTT_DISCOVERY_DEVICE = {
|
|||||||
"model": "XVF3800",
|
"model": "XVF3800",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Настройки яркости
|
||||||
BRIGHTNESS_CONFIG = {
|
BRIGHTNESS_CONFIG = {
|
||||||
"listening": 120, "wake": 200, "thinking": 150,
|
"listening": 120, "wake": 200, "thinking": 150,
|
||||||
"speaking": 140, "idle": 80, "paused": 40, "error": 255,
|
"speaking": 140, "idle": 80, "paused": 40, "error": 255,
|
||||||
@@ -74,10 +76,11 @@ def load_brightness_config():
|
|||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE) as f:
|
with open(CONFIG_FILE) as f:
|
||||||
BRIGHTNESS_CONFIG.update(json.load(f))
|
saved = json.load(f)
|
||||||
logger.info("✅ Загружены настройки яркости")
|
BRIGHTNESS_CONFIG.update(saved)
|
||||||
|
logger.info(f"📂 Загружены настройки яркости из {CONFIG_FILE}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Ошибка загрузки config: {e}")
|
logger.warning(f"⚠️ Ошибка загрузки config: {e}")
|
||||||
else:
|
else:
|
||||||
save_brightness_config()
|
save_brightness_config()
|
||||||
|
|
||||||
@@ -85,8 +88,9 @@ def save_brightness_config():
|
|||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE, "w") as f:
|
with open(CONFIG_FILE, "w") as f:
|
||||||
json.dump(BRIGHTNESS_CONFIG, f, indent=2)
|
json.dump(BRIGHTNESS_CONFIG, f, indent=2)
|
||||||
|
logger.info(f"💾 Сохранено в {CONFIG_FILE}: {BRIGHTNESS_CONFIG}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка сохранения config: {e}")
|
logger.error(f"❌ Ошибка сохранения config: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ====================== LED ======================
|
# ====================== LED ======================
|
||||||
@@ -98,13 +102,14 @@ def run_xvf_command(command: str, values=None):
|
|||||||
subprocess.run(args, capture_output=True, text=True, check=True, timeout=5)
|
subprocess.run(args, capture_output=True, text=True, check=True, timeout=5)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LED error {command}: {e}")
|
logger.error(f"❌ LED command error {command}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_effective_brightness(state: str) -> int:
|
def get_effective_brightness(state: str) -> int:
|
||||||
base = BRIGHTNESS_CONFIG.get(state, 100)
|
base = BRIGHTNESS_CONFIG.get(state, 100)
|
||||||
return int(base * BRIGHTNESS_CONFIG.get("global_multiplier", 1.0))
|
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
||||||
|
return int(base * multiplier)
|
||||||
|
|
||||||
|
|
||||||
def set_led_state(state: str, skip_publish=False):
|
def set_led_state(state: str, skip_publish=False):
|
||||||
@@ -117,44 +122,56 @@ def set_led_state(state: str, skip_publish=False):
|
|||||||
run_xvf_command("LED_COLOR", ["0x00BCD4"])
|
run_xvf_command("LED_COLOR", ["0x00BCD4"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
run_xvf_command("LED_SPEED", [2])
|
run_xvf_command("LED_SPEED", [2])
|
||||||
|
logger.info(f"🔵 Listening: бирюзовое дыхание ({brightness})")
|
||||||
elif state == "wake":
|
elif state == "wake":
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0xFF0000"])
|
run_xvf_command("LED_COLOR", ["0xFF0000"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
|
logger.info(f"🔴 Wake: красный ({brightness})")
|
||||||
elif state == "thinking":
|
elif state == "thinking":
|
||||||
run_xvf_command("LED_EFFECT", [2])
|
run_xvf_command("LED_EFFECT", [2])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
run_xvf_command("LED_SPEED", [4])
|
run_xvf_command("LED_SPEED", [4])
|
||||||
|
logger.info(f"🌈 Thinking: радуга ({brightness})")
|
||||||
elif state == "speaking":
|
elif state == "speaking":
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0x4CAF50"])
|
run_xvf_command("LED_COLOR", ["0x4CAF50"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
|
logger.info(f"🟢 Speaking: зелёный ({brightness})")
|
||||||
elif state == "idle":
|
elif state == "idle":
|
||||||
run_xvf_command("LED_EFFECT", [4])
|
run_xvf_command("LED_EFFECT", [4])
|
||||||
run_xvf_command("LED_DOA_COLOR", ["0x303030", "0x00BCD4"])
|
run_xvf_command("LED_DOA_COLOR", ["0x303030", "0x00BCD4"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
|
logger.info(f"⚪ Idle: DoA ({brightness})")
|
||||||
elif state == "paused":
|
elif state == "paused":
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0x404040"])
|
run_xvf_command("LED_COLOR", ["0x404040"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
|
logger.info(f"⚫ Paused: серый ({brightness})")
|
||||||
elif state == "error":
|
elif state == "error":
|
||||||
run_xvf_command("LED_EFFECT", [1])
|
run_xvf_command("LED_EFFECT", [1])
|
||||||
run_xvf_command("LED_COLOR", ["0xFF00FF"])
|
run_xvf_command("LED_COLOR", ["0xFF00FF"])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
run_xvf_command("LED_BRIGHTNESS", [brightness])
|
||||||
|
logger.info(f"🟣 Error: маджента ({brightness})")
|
||||||
elif state == "off":
|
elif state == "off":
|
||||||
run_xvf_command("LED_EFFECT", [0])
|
run_xvf_command("LED_EFFECT", [0])
|
||||||
run_xvf_command("LED_BRIGHTNESS", [0])
|
run_xvf_command("LED_BRIGHTNESS", [0])
|
||||||
|
logger.info("⬛ Off: выключено")
|
||||||
|
|
||||||
if not skip_publish:
|
if not skip_publish:
|
||||||
publish_state(state, brightness)
|
publish_state(state)
|
||||||
|
publish_satellite_state(state)
|
||||||
|
|
||||||
|
|
||||||
# ====================== MQTT ======================
|
# ====================== MQTT PUBLISH ======================
|
||||||
def publish_discovery_config():
|
def publish_discovery_config():
|
||||||
if not mqtt_client:
|
if not mqtt_client:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
discovery = {
|
logger.info("🔄 Публикация Discovery...")
|
||||||
|
|
||||||
|
# Light
|
||||||
|
light_disc = {
|
||||||
"name": "Wyoming LED",
|
"name": "Wyoming LED",
|
||||||
"unique_id": "wyoming_led_light",
|
"unique_id": "wyoming_led_light",
|
||||||
"state_topic": MQTT_TOPIC_STATE,
|
"state_topic": MQTT_TOPIC_STATE,
|
||||||
@@ -166,121 +183,178 @@ def publish_discovery_config():
|
|||||||
"payload_not_available": "offline",
|
"payload_not_available": "offline",
|
||||||
"payload_on": "ON",
|
"payload_on": "ON",
|
||||||
"payload_off": "OFF",
|
"payload_off": "OFF",
|
||||||
"brightness_scale": 100, # ← ИЗМЕНИЛИ НА 100
|
"brightness_scale": 100,
|
||||||
"optimistic": False,
|
"optimistic": False,
|
||||||
"qos": 1,
|
"qos": 1,
|
||||||
"device": MQTT_DISCOVERY_DEVICE,
|
"device": MQTT_DISCOVERY_DEVICE,
|
||||||
}
|
}
|
||||||
|
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config",
|
||||||
|
json.dumps(light_disc), retain=True, qos=1)
|
||||||
|
|
||||||
mqtt_client.publish(
|
# Sensor
|
||||||
f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config",
|
sensor_disc = {
|
||||||
json.dumps(discovery),
|
"name": "Wyoming Satellite State",
|
||||||
retain=True,
|
"unique_id": "wyoming_satellite_state",
|
||||||
qos=1
|
"state_topic": MQTT_TOPIC_SATELLITE_STATE,
|
||||||
)
|
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
||||||
logger.info("✅ Discovery опубликован (scale = 100)")
|
"payload_available": "online",
|
||||||
|
"payload_not_available": "offline",
|
||||||
|
"device": MQTT_DISCOVERY_DEVICE,
|
||||||
|
}
|
||||||
|
mqtt_client.publish(f"{MQTT_DISCOVERY_PREFIX}/sensor/wyoming_satellite_state/config",
|
||||||
|
json.dumps(sensor_disc), retain=True, qos=1)
|
||||||
|
|
||||||
|
# Number entities
|
||||||
|
publish_number_discovery()
|
||||||
|
|
||||||
|
logger.info("✅ Discovery опубликован успешно (Light + Sensor + 7 Number)")
|
||||||
|
# Состояния будут опубликованы с задержкой в on_mqtt_connect
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Discovery error: {e}")
|
logger.error(f"❌ Ошибка Discovery: {e}", exc_info=True)
|
||||||
|
|
||||||
def publish_state(state: str, brightness: int = None):
|
|
||||||
"""Публикуем в HA именно global_multiplier, а не effective brightness"""
|
def publish_number_discovery():
|
||||||
if not mqtt_client:
|
if not mqtt_client:
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
modes = {
|
||||||
|
"idle": "Idle", "listening": "Listening", "wake": "Wake",
|
||||||
|
"thinking": "Thinking", "speaking": "Speaking",
|
||||||
|
"paused": "Paused", "error": "Error"
|
||||||
|
}
|
||||||
|
for mode, name in modes.items():
|
||||||
|
disc = {
|
||||||
|
"name": f"{name}",
|
||||||
|
"unique_id": f"wyoming_led_{mode}_base",
|
||||||
|
"object_id": f"wyoming_led_{mode}_base",
|
||||||
|
"state_topic": f"wyoming/led/{mode}/state",
|
||||||
|
"command_topic": f"wyoming/led/{mode}/set",
|
||||||
|
"availability_topic": MQTT_TOPIC_AVAILABILITY,
|
||||||
|
"payload_available": "online",
|
||||||
|
"payload_not_available": "offline",
|
||||||
|
"min": 0, "max": 100, "step": 5,
|
||||||
|
"unit_of_measurement": "%",
|
||||||
|
|
||||||
|
"icon": "mdi:led-on",
|
||||||
|
"device": MQTT_DISCOVERY_DEVICE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
mqtt_client.publish(
|
||||||
|
f"{MQTT_DISCOVERY_PREFIX}/number/wyoming_led_{mode}/config",
|
||||||
|
json.dumps(disc), retain=True, qos=1
|
||||||
|
)
|
||||||
|
logger.info("✅ 7 Number entities опубликованы")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка Number discovery: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def publish_state(state: str):
|
||||||
|
if not mqtt_client: return
|
||||||
ha_state = "OFF" if (ha_disabled or state == "off") else "ON"
|
ha_state = "OFF" if (ha_disabled or state == "off") else "ON"
|
||||||
|
|
||||||
# Главное: публикуем текущий global_multiplier в процентах (0-100)
|
|
||||||
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
multiplier = BRIGHTNESS_CONFIG.get("global_multiplier", 1.0)
|
||||||
ha_brightness = int(multiplier * 100) # 0..100
|
ha_brightness = int(multiplier * 100)
|
||||||
|
|
||||||
mqtt_client.publish(MQTT_TOPIC_STATE, ha_state, retain=True, qos=1)
|
mqtt_client.publish(MQTT_TOPIC_STATE, ha_state, retain=True, qos=1)
|
||||||
mqtt_client.publish(MQTT_TOPIC_BRIGHTNESS_STATE, str(ha_brightness), retain=True, qos=1)
|
mqtt_client.publish(MQTT_TOPIC_BRIGHTNESS_STATE, str(ha_brightness), retain=True, qos=1)
|
||||||
|
|
||||||
logger.info(f"📤 HA ← {ha_state} | Яркость: {ha_brightness}% (multiplier: {multiplier:.2f})")
|
|
||||||
|
|
||||||
|
def publish_satellite_state(state: str):
|
||||||
|
if mqtt_client:
|
||||||
|
mqtt_client.publish(MQTT_TOPIC_SATELLITE_STATE, state, retain=True, qos=0)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_base_brightness_states():
|
||||||
|
if not mqtt_client: return
|
||||||
|
try:
|
||||||
|
logger.debug(f"📤 Brightness states: {BRIGHTNESS_CONFIG}")
|
||||||
|
for mode in ["idle","listening","wake","thinking","speaking","paused","error"]:
|
||||||
|
if mode in BRIGHTNESS_CONFIG:
|
||||||
|
percent = min(100, int(BRIGHTNESS_CONFIG[mode] / 2.55))
|
||||||
|
mqtt_client.publish(f"wyoming/led/{mode}/state", str(percent), retain=True, qos=1)
|
||||||
|
logger.debug(f"📤 {mode}/state = {percent}% (value={BRIGHTNESS_CONFIG[mode]})")
|
||||||
|
logger.info("📊 Brightness states опубликованы")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка publish_base_brightness_states: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== MQTT CALLBACKS ======================
|
||||||
def on_mqtt_connect(client, userdata, flags, rc, properties=None):
|
def on_mqtt_connect(client, userdata, flags, rc, properties=None):
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
logger.info("✅ MQTT подключён")
|
logger.info(f"✅ MQTT подключён к {MQTT_BROKER}:{MQTT_PORT}")
|
||||||
client.publish(MQTT_TOPIC_AVAILABILITY, "online", retain=True, qos=1)
|
client.publish(MQTT_TOPIC_AVAILABILITY, "online", retain=True, qos=1)
|
||||||
client.subscribe(MQTT_TOPIC_COMMAND)
|
client.subscribe(MQTT_TOPIC_COMMAND)
|
||||||
client.subscribe(MQTT_TOPIC_BRIGHTNESS_SET)
|
client.subscribe(MQTT_TOPIC_BRIGHTNESS_SET)
|
||||||
|
# Подписка на топики 7 режимов
|
||||||
|
for mode in ["idle","listening","wake","thinking","speaking","paused","error"]:
|
||||||
|
client.subscribe(f"wyoming/led/{mode}/set")
|
||||||
|
logger.debug(f"📡 Подписка на wyoming/led/{mode}/set")
|
||||||
publish_discovery_config()
|
publish_discovery_config()
|
||||||
|
publish_state(current_state)
|
||||||
# Отправляем текущее состояние
|
publish_satellite_state(current_state)
|
||||||
brightness = get_effective_brightness(current_state)
|
publish_base_brightness_states()
|
||||||
publish_state(current_state, brightness)
|
logger.info(f"📡 Подписки: {MQTT_TOPIC_COMMAND}, {MQTT_TOPIC_BRIGHTNESS_SET}, 7 режимов")
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ MQTT ошибка: {rc}")
|
logger.error(f"❌ MQTT ошибка подключения: {rc}")
|
||||||
|
|
||||||
|
|
||||||
def on_mqtt_message(client, userdata, msg):
|
def on_mqtt_message(client, userdata, msg):
|
||||||
global ha_disabled, last_mqtt_brightness, current_state
|
global ha_disabled, last_mqtt_brightness, current_state
|
||||||
try:
|
try:
|
||||||
payload = msg.payload.decode('utf-8').strip()
|
payload = msg.payload.decode('utf-8').strip()
|
||||||
logger.info(f"📩 {msg.topic}: {payload}")
|
topic = msg.topic
|
||||||
|
logger.info(f"📩 MQTT {topic}: {payload}")
|
||||||
|
|
||||||
# ==================== ЯРКОСТЬ ====================
|
# Глобальная яркость
|
||||||
if msg.topic == MQTT_TOPIC_BRIGHTNESS_SET:
|
if topic == MQTT_TOPIC_BRIGHTNESS_SET:
|
||||||
try:
|
ha_value = max(0, min(100, int(payload)))
|
||||||
ha_value = max(0, min(100, int(payload))) # 0-100
|
new_multiplier = ha_value / 100.0
|
||||||
new_multiplier = ha_value / 100.0
|
last_mqtt_brightness = ha_value
|
||||||
|
BRIGHTNESS_CONFIG["global_multiplier"] = new_multiplier
|
||||||
|
save_brightness_config()
|
||||||
|
logger.info(f"🔆 Глобальная яркость: {ha_value}% (×{new_multiplier:.2f})")
|
||||||
|
if not ha_disabled:
|
||||||
|
set_led_state(current_state)
|
||||||
|
publish_base_brightness_states()
|
||||||
|
|
||||||
last_mqtt_brightness = ha_value
|
# Базовая яркость режима
|
||||||
BRIGHTNESS_CONFIG["global_multiplier"] = new_multiplier
|
elif topic.startswith("wyoming/led/") and topic.endswith("/set"):
|
||||||
|
mode = topic.split("/")[2]
|
||||||
|
logger.info(f"🎚️ Получена команда для режима: {mode}")
|
||||||
|
if mode in BRIGHTNESS_CONFIG:
|
||||||
|
percent = max(0, min(100, int(payload)))
|
||||||
|
real_value = int(percent * 2.55)
|
||||||
|
old_value = BRIGHTNESS_CONFIG[mode]
|
||||||
|
BRIGHTNESS_CONFIG[mode] = real_value
|
||||||
save_brightness_config()
|
save_brightness_config()
|
||||||
|
logger.info(f"🎛️ {mode}: {old_value} → {real_value} ({percent}%)")
|
||||||
logger.info(f"🔆 HA установил яркость: {ha_value}% → multiplier = {new_multiplier:.2f}")
|
logger.info(f"📤 Публикация {mode}/state = {percent}")
|
||||||
|
publish_base_brightness_states()
|
||||||
if not ha_disabled:
|
if current_state == mode and not ha_disabled:
|
||||||
# Применяем новую яркость к текущему состоянию
|
logger.info(f"🔄 Применяем новую яркость к текущему состоянию {mode}")
|
||||||
set_led_state(current_state, skip_publish=False)
|
set_led_state(mode)
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(f"Неверное значение яркости: {payload}")
|
|
||||||
# ==================== КОМАНДА (ON / OFF) ====================
|
|
||||||
elif msg.topic == MQTT_TOPIC_COMMAND:
|
|
||||||
ha_state = payload.upper()
|
|
||||||
|
|
||||||
if ha_state in ["ON", "OFF"]:
|
|
||||||
if ha_state == "OFF":
|
|
||||||
logger.info("🔴 HA → OFF")
|
|
||||||
ha_disabled = True
|
|
||||||
run_xvf_command("LED_EFFECT", [0])
|
|
||||||
run_xvf_command("LED_BRIGHTNESS", [0])
|
|
||||||
publish_state("off", 0)
|
|
||||||
else: # ON
|
|
||||||
logger.info("🟢 HA → ON")
|
|
||||||
ha_disabled = False
|
|
||||||
set_led_state(current_state)
|
|
||||||
else:
|
else:
|
||||||
# Если вдруг пришёл JSON (редкий случай)
|
logger.warning(f"⚠️ Неизвестный режим: {mode}")
|
||||||
try:
|
|
||||||
cmd = json.loads(payload)
|
|
||||||
ha_state = str(cmd.get("state", "")).upper()
|
|
||||||
bright = cmd.get("brightness")
|
|
||||||
|
|
||||||
if ha_state == "OFF":
|
# ON / OFF
|
||||||
ha_disabled = True
|
elif topic == MQTT_TOPIC_COMMAND:
|
||||||
run_xvf_command("LED_EFFECT", [0])
|
ha_state = payload.upper()
|
||||||
run_xvf_command("LED_BRIGHTNESS", [0])
|
if ha_state == "OFF":
|
||||||
publish_state("off", 0)
|
ha_disabled = True
|
||||||
elif ha_state == "ON":
|
run_xvf_command("LED_EFFECT", [0])
|
||||||
ha_disabled = False
|
run_xvf_command("LED_BRIGHTNESS", [0])
|
||||||
if bright is not None:
|
publish_state("off")
|
||||||
bright = max(0, min(255, int(bright)))
|
logger.info("⬛ LED OFF")
|
||||||
last_mqtt_brightness = bright
|
elif ha_state == "ON":
|
||||||
BRIGHTNESS_CONFIG["global_multiplier"] = bright / 255.0
|
ha_disabled = False
|
||||||
save_brightness_config()
|
set_led_state(current_state)
|
||||||
set_led_state(current_state)
|
logger.info("✅ LED ON")
|
||||||
except:
|
|
||||||
logger.warning(f"Неизвестная команда: {payload}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка обработки MQTT: {e}", exc_info=True)
|
logger.error(f"❌ Ошибка MQTT: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
# ====================== START ======================
|
# ====================== MQTT CLIENT ======================
|
||||||
def start_mqtt_client():
|
def start_mqtt_client():
|
||||||
global mqtt_client
|
global mqtt_client
|
||||||
mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID)
|
mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID)
|
||||||
@@ -288,41 +362,59 @@ def start_mqtt_client():
|
|||||||
mqtt_client.on_connect = on_mqtt_connect
|
mqtt_client.on_connect = on_mqtt_connect
|
||||||
mqtt_client.on_message = on_mqtt_message
|
mqtt_client.on_message = on_mqtt_message
|
||||||
mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1)
|
mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1)
|
||||||
|
|
||||||
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
||||||
mqtt_client.loop_start()
|
mqtt_client.loop_start()
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== HANDLER & MAIN ======================
|
||||||
class LedEventHandler(AsyncEventHandler):
|
class LedEventHandler(AsyncEventHandler):
|
||||||
async def handle_event(self, event: Event) -> bool:
|
async def handle_event(self, event: Event) -> bool:
|
||||||
if ha_disabled:
|
if ha_disabled:
|
||||||
return True
|
return True
|
||||||
et = event.type
|
et = event.type
|
||||||
|
logger.debug(f"🔍 Событие: {et}")
|
||||||
if Detection.is_type(et):
|
if Detection.is_type(et):
|
||||||
|
logger.info("🎯 Detection → wake")
|
||||||
set_led_state("wake")
|
set_led_state("wake")
|
||||||
elif VoiceStarted.is_type(et):
|
elif VoiceStarted.is_type(et):
|
||||||
|
logger.info("🎤 VoiceStarted → listening")
|
||||||
set_led_state("listening")
|
set_led_state("listening")
|
||||||
elif VoiceStopped.is_type(et):
|
elif VoiceStopped.is_type(et):
|
||||||
|
logger.info("💭 VoiceStopped → thinking")
|
||||||
set_led_state("thinking")
|
set_led_state("thinking")
|
||||||
elif et == "audio-start":
|
elif et == "audio-start":
|
||||||
|
logger.info("🔊 audio-start → speaking")
|
||||||
set_led_state("speaking")
|
set_led_state("speaking")
|
||||||
elif Played.is_type(et):
|
elif Played.is_type(et):
|
||||||
|
logger.info("✅ Played → idle")
|
||||||
set_led_state("idle")
|
set_led_state("idle")
|
||||||
elif et == "pause-satellite":
|
elif et == "pause-satellite":
|
||||||
|
logger.info("⏸️ pause → paused")
|
||||||
set_led_state("paused")
|
set_led_state("paused")
|
||||||
elif SatelliteConnected.is_type(et):
|
elif SatelliteConnected.is_type(et):
|
||||||
|
logger.info("🔌 connected → idle")
|
||||||
set_led_state("idle")
|
set_led_state("idle")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def on_connected(self) -> None:
|
||||||
|
logger.info("✅ TCP подключён")
|
||||||
|
async def on_disconnected(self) -> None:
|
||||||
|
logger.info("❌ TCP отключён")
|
||||||
|
set_led_state("idle")
|
||||||
|
|
||||||
|
|
||||||
def cleanup_leds():
|
def cleanup_leds():
|
||||||
logger.info("🧹 Выключаем LED")
|
logger.info("🧹 Очистка: выключаем LED...")
|
||||||
run_xvf_command("LED_EFFECT", [0])
|
try:
|
||||||
run_xvf_command("LED_BRIGHTNESS", [0])
|
run_xvf_command("LED_EFFECT", [0])
|
||||||
|
run_xvf_command("LED_BRIGHTNESS", [0])
|
||||||
|
logger.info("✅ LED выключены")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка очистки: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def main(args):
|
async def main(args):
|
||||||
logger.info("🚀 Запуск Wyoming LED Service")
|
logger.info(f"🚀 Запуск на {args.host}:{args.port}")
|
||||||
load_brightness_config()
|
load_brightness_config()
|
||||||
start_mqtt_client()
|
start_mqtt_client()
|
||||||
run_xvf_command("VERSION")
|
run_xvf_command("VERSION")
|
||||||
@@ -330,19 +422,18 @@ async def main(args):
|
|||||||
|
|
||||||
uri = f"tcp://{args.host}:{args.port}"
|
uri = f"tcp://{args.host}:{args.port}"
|
||||||
server = AsyncServer.from_uri(uri)
|
server = AsyncServer.from_uri(uri)
|
||||||
|
logger.info(f"📡 Слушаем {uri}")
|
||||||
await server.run(partial(LedEventHandler))
|
await server.run(partial(LedEventHandler))
|
||||||
|
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
logger.info(f"🛑 Сигнал {sig}")
|
logger.info(f"\n🛑 Сигнал {sig}")
|
||||||
cleanup_leds()
|
cleanup_leds()
|
||||||
if mqtt_client:
|
if mqtt_client:
|
||||||
try:
|
mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True)
|
||||||
mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True)
|
mqtt_client.loop_stop()
|
||||||
mqtt_client.loop_stop()
|
mqtt_client.disconnect()
|
||||||
mqtt_client.disconnect()
|
logger.info("📡 MQTT остановлен")
|
||||||
except:
|
|
||||||
pass
|
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+115
@@ -0,0 +1,115 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Установка systemd сервисов для Wyoming Satellite
|
||||||
|
# Автоматически определяет пути и подставляет их в service файлы
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SERVICE_DIR="${SCRIPT_DIR}/services"
|
||||||
|
SYSTEMD_DIR="/etc/systemd/system"
|
||||||
|
|
||||||
|
echo "🔧 Установка systemd сервисов для Wyoming Satellite"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
# Определяем текущего пользователя
|
||||||
|
CURRENT_USER="${SUDO_USER:-$(whoami)}"
|
||||||
|
HOME_DIR=$(getent passwd "$CURRENT_USER" | cut -d: -f6)
|
||||||
|
|
||||||
|
echo "👤 Пользователь: $CURRENT_USER"
|
||||||
|
echo "🏠 Домашняя директория: $HOME_DIR"
|
||||||
|
|
||||||
|
# Ищем виртуальное окружение
|
||||||
|
VENV_PATH=""
|
||||||
|
for candidate in \
|
||||||
|
"${HOME_DIR}/projects/.venv_satellite" \
|
||||||
|
"${HOME_DIR}/.venv_satellite" \
|
||||||
|
"${SCRIPT_DIR}/.venv" \
|
||||||
|
"${SCRIPT_DIR}/.venv_satellite"
|
||||||
|
do
|
||||||
|
if [ -f "${candidate}/bin/python3" ]; then
|
||||||
|
VENV_PATH="${candidate}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$VENV_PATH" ]; then
|
||||||
|
echo "❌ Виртуальное окружение не найдено!"
|
||||||
|
echo " Создайте его: python3 -m venv /path/to/venv"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ VENV: $VENV_PATH"
|
||||||
|
|
||||||
|
# Устанавливаем глобальный wrapper для Python
|
||||||
|
WRAPPER_SRC="${SCRIPT_DIR}/scripts/satellite-python"
|
||||||
|
WRAPPER_DST="/usr/local/bin/satellite-python"
|
||||||
|
|
||||||
|
if [ -f "$WRAPPER_SRC" ]; then
|
||||||
|
# Обновляем путь к venv в wrapper'е
|
||||||
|
sed -i "s|VENV_PATH=.*|VENV_PATH=\"${VENV_PATH}\"|" "$WRAPPER_SRC" 2>/dev/null || true
|
||||||
|
sudo cp "$WRAPPER_SRC" "$WRAPPER_DST"
|
||||||
|
sudo chmod +x "$WRAPPER_DST"
|
||||||
|
echo "✅ Wrapper установлен: $WRAPPER_DST"
|
||||||
|
else
|
||||||
|
echo "⚠️ Wrapper не найден, используем полные пути"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Создаём временную директорию для обработанных сервисов
|
||||||
|
TEMP_DIR=$(mktemp -d)
|
||||||
|
trap "rm -rf ${TEMP_DIR}" EXIT
|
||||||
|
|
||||||
|
# Обрабатываем каждый 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 "✅ Установка завершена!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Доступные сервисы:"
|
||||||
|
echo " - wyoming-event.service (LED управление)"
|
||||||
|
echo " - wyoming-satelite-vosk.service (спутник)"
|
||||||
|
echo " - wyoming-wakeword-vosk.service (wake word)"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Команды управления:"
|
||||||
|
echo " # LED сервис"
|
||||||
|
echo " sudo systemctl enable wyoming-event.service"
|
||||||
|
echo " sudo systemctl start wyoming-event.service"
|
||||||
|
echo " sudo systemctl status wyoming-event.service"
|
||||||
|
echo ""
|
||||||
|
echo " # Wyoming Satellite"
|
||||||
|
echo " sudo systemctl enable wyoming-satelite-vosk.service"
|
||||||
|
echo " sudo systemctl start wyoming-satelite-vosk.service"
|
||||||
|
echo " sudo systemctl status wyoming-satelite-vosk.service"
|
||||||
|
echo ""
|
||||||
|
echo " # Wake Word сервис"
|
||||||
|
echo " sudo systemctl enable wyoming-wakeword-vosk.service"
|
||||||
|
echo " sudo systemctl start wyoming-wakeword-vosk.service"
|
||||||
|
echo " sudo systemctl status wyoming-wakeword-vosk.service"
|
||||||
|
echo ""
|
||||||
|
echo "📊 Просмотр логов:"
|
||||||
|
echo " journalctl -u wyoming-event.service -f"
|
||||||
|
echo " journalctl -u wyoming-satelite-vosk.service -f"
|
||||||
|
echo " journalctl -u wyoming-wakeword-vosk.service -f"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
certifi==2026.4.22
|
||||||
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
flatbuffers==20181003210633
|
||||||
|
idna==3.15
|
||||||
|
ifaddr==0.2.0
|
||||||
|
importlib_resources==7.1.0
|
||||||
|
libusb-package==1.0.26.3
|
||||||
|
numpy==1.25.2
|
||||||
|
onnxruntime==1.26.0
|
||||||
|
packaging==26.2
|
||||||
|
paho-mqtt==2.1.0
|
||||||
|
protobuf==7.34.1
|
||||||
|
psutil==7.2.2
|
||||||
|
pycparser==3.0
|
||||||
|
pyring_buffer==1.1.0
|
||||||
|
pysilero-vad==1.0.0
|
||||||
|
pyusb==1.3.1
|
||||||
|
PyYAML==6.0.3
|
||||||
|
requests==2.34.0
|
||||||
|
srt==3.5.3
|
||||||
|
tqdm==4.67.3
|
||||||
|
urllib3==2.7.0
|
||||||
|
vosk==0.3.45
|
||||||
|
webrtc-noise-gain==1.2.3
|
||||||
|
webrtcvad==2.0.10
|
||||||
|
websockets==16.0
|
||||||
|
wyoming==1.5.4
|
||||||
|
-e git+ssh://gitea@192.168.200.208/sirmrak/satellite.git@8f2ff703db66aab33d0f8f43919a1ba5e92d5439#egg=wyoming_satellite
|
||||||
|
zeroconf==0.88.0
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Глобальный wrapper для Python из venv Satellite
|
||||||
|
# Используется в systemd сервисах
|
||||||
|
#
|
||||||
|
|
||||||
|
VENV_PATH="/home/sirmrak/projects/.venv_satellite"
|
||||||
|
|
||||||
|
# Проверяем существование venv
|
||||||
|
if [ ! -f "${VENV_PATH}/bin/python3" ]; then
|
||||||
|
echo "❌ VENV не найден: ${VENV_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "${VENV_PATH}/bin/python3" "$@"
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=voskWakeWord
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=sirmrak
|
|
||||||
ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python \
|
|
||||||
/home/sirmrak/projects/Satellite/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 "привет дройд"
|
|
||||||
|
|
||||||
WorkingDirectory=/home/sirmrak/projects/Satellite/local/vosk-wake-dev
|
|
||||||
Restart=always
|
|
||||||
RestartSec=1
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -10,16 +10,16 @@ User=sirmrak
|
|||||||
Group=sirmrak
|
Group=sirmrak
|
||||||
|
|
||||||
# 🔹 Рабочая директория — где лежит wyoming-led.py
|
# 🔹 Рабочая директория — где лежит wyoming-led.py
|
||||||
WorkingDirectory=/home/sirmrak/projects/Satellite/examples
|
WorkingDirectory=__SAT_BASE_DIR__/examples
|
||||||
|
|
||||||
# 🔹 Прямой запуск через python из виртуального окружения (без обёрток!)
|
# 🔹 Запуск через wrapper (путь подставляется при установке)
|
||||||
ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python3 /home/sirmrak/projects/Satellite/examples/wyoming-led.py --host 127.0.0.1 --port 10500
|
ExecStart=satellite-python __SAT_BASE_DIR__/examples/wyoming-led.py --host 127.0.0.1 --port 10500
|
||||||
|
|
||||||
# 🔹 Критически важно: передаём окружение
|
# 🔹 Критически важно: передаём окружение
|
||||||
Environment="HOME=/home/sirmrak"
|
Environment="HOME=__HOME_DIR__"
|
||||||
Environment="USER=sirmrak"
|
Environment="USER=__CURRENT_USER__"
|
||||||
Environment="PATH=/home/sirmrak/projects/.venv_satellite/bin:/usr/local/bin:/usr/bin:/bin"
|
Environment="PATH=__SAT_VENV__/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
Environment="VIRTUAL_ENV=/home/sirmrak/projects/.venv_satellite"
|
Environment="VIRTUAL_ENV=__SAT_VENV__"
|
||||||
|
|
||||||
# 🔹 Разрешаем доступ к устройству (на всякий случай)
|
# 🔹 Разрешаем доступ к устройству (на всякий случай)
|
||||||
PrivateDevices=false
|
PrivateDevices=false
|
||||||
@@ -4,9 +4,9 @@ Requires=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=sirmrak
|
User=__CURRENT_USER__
|
||||||
WorkingDirectory=/home/sirmrak/projects/Satellite
|
WorkingDirectory=__SAT_BASE_DIR__
|
||||||
ExecStart=/home/sirmrak/projects/Satellite/start_satellite.sh
|
ExecStart=__SAT_BASE_DIR__/start_satellite.sh
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=voskWakeWord
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=__CURRENT_USER__
|
||||||
|
ExecStart=satellite-python __SAT_BASE_DIR__/local/vosk-wake-dev/script/run \
|
||||||
|
--uri tcp://0.0.0.0:10400 \
|
||||||
|
--model-path /home/__CURRENT_USER__/projects/models/vosk-model-small-ru-0.22 \
|
||||||
|
--rate 16000 \
|
||||||
|
--wake-word "привет дройд"
|
||||||
|
|
||||||
|
WorkingDirectory=__SAT_BASE_DIR__/local/vosk-wake-dev
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
|
||||||
|
# Путь к 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
|
||||||
|
|
||||||
|
if [ -z "$VENV_PATH" ]; then
|
||||||
|
echo "❌ Виртуальное окружение не найдено!" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PY="${VENV_PATH}/bin/python3"
|
||||||
|
LED_SCRIPT="${SCRIPT_DIR}/examples/wyoming-led.py"
|
||||||
|
|
||||||
|
echo "🚀 Запуск LED сервиса..."
|
||||||
|
echo "📡 Wyoming Satellite: tcp://0.0.0.0:10701"
|
||||||
|
echo "📡 MQTT Broker: 192.168.200.100:1883"
|
||||||
|
|
||||||
|
exec "$PY" "$LED_SCRIPT" --host 0.0.0.0 --port 10701
|
||||||
+21
-3
@@ -5,15 +5,33 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
export PYTHONPATH="${SCRIPT_DIR}"
|
export PYTHONPATH="${SCRIPT_DIR}"
|
||||||
|
|
||||||
# Путь к Python-окружению
|
# Путь к Python-окружению (ищем в стандартных местах)
|
||||||
PY="/home/sirmrak/projects/.venv_satellite/bin/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
|
||||||
|
|
||||||
|
if [ -z "$VENV_PATH" ]; then
|
||||||
|
echo "❌ Виртуальное окружение не найдено!" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PY="${VENV_PATH}/bin/python3"
|
||||||
|
|
||||||
# Режим запуска: wake|vad (по умолчанию wake)
|
# Режим запуска: wake|vad (по умолчанию wake)
|
||||||
MODE="${1:-wake}"
|
MODE="${1:-wake}"
|
||||||
|
|
||||||
BASE_ARGS=(
|
BASE_ARGS=(
|
||||||
--debug
|
--debug
|
||||||
--name 'Wyoming Pi Satellite'
|
--name 'Wyoming Pi Vosk Satellite'
|
||||||
--uri tcp://0.0.0.0:10700
|
--uri tcp://0.0.0.0:10700
|
||||||
--mic-command 'arecord -D plughw:CARD=Array,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw'
|
--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'
|
--snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw'
|
||||||
|
|||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Делаем текущий проект доступным для Python
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
export PYTHONPATH="${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
# Путь к Python-окружению
|
||||||
|
PY="/home/sirmrak/projects/.venv_satellite/bin/python"
|
||||||
|
|
||||||
|
# Режим запуска: wake|vad (по умолчанию wake)
|
||||||
|
MODE="${1:-wake}"
|
||||||
|
|
||||||
|
BASE_ARGS=(
|
||||||
|
--debug
|
||||||
|
--name 'Wyoming Pi Satellite Test'
|
||||||
|
--uri tcp://0.0.0.0:10701
|
||||||
|
--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'
|
||||||
|
--awake-wav /home/sirmrak/projects/Satellite/sounds/awake.wav
|
||||||
|
#--mic-noise-suppression 2
|
||||||
|
#--mic-auto-gain 20
|
||||||
|
--event-uri tcp://127.0.0.1:10500
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$MODE" == "wake" ]]; then
|
||||||
|
# Локальный wakeword-сервис (Vosk). VAD в этом режиме не используется спутником.
|
||||||
|
EXTRA_ARGS=(
|
||||||
|
--wake-uri tcp://127.0.0.1:10400
|
||||||
|
#--wake-word-name "привет дроид" \
|
||||||
|
--wake-word-name "привет придурок" \
|
||||||
|
#--wake-word-name "привет жопа с ушами"
|
||||||
|
# Добавьте дополнительные фразы при необходимости:
|
||||||
|
# --wake-word-name "привет дурик" \
|
||||||
|
# --wake-word-name "привет говнюк"
|
||||||
|
)
|
||||||
|
elif [[ "$MODE" == "vad" ]]; then
|
||||||
|
# Локальный VAD (WebRTC+энергия) без wakeword-сервиса
|
||||||
|
EXTRA_ARGS=(
|
||||||
|
--vad
|
||||||
|
--vad-system webrtc
|
||||||
|
--vad-trigger-level 5 # ≈100мс при 20мс окне
|
||||||
|
--vad-energy-threshold 200
|
||||||
|
--vad-buffer-seconds 1
|
||||||
|
--vad-wake-word-timeout 5
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "Неизвестный режим: $MODE (ожидается: wake|vad)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$PY" -m wyoming_satellite "${BASE_ARGS[@]}" "${EXTRA_ARGS[@]}"
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Wyoming LED Service with MQTT (ReSpeaker XVF3800)
|
||||||
|
After=network.target mosquitto.service
|
||||||
|
Wants=mosquitto.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=sirmrak
|
||||||
|
Group=sirmrak
|
||||||
|
WorkingDirectory=/home/sirmrak/projects/Satellite
|
||||||
|
Environment="PATH=/home/sirmrak/projects/.venv_satellite/bin"
|
||||||
|
ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python3 /home/sirmrak/projects/Satellite/examples/wyoming-led.py --host 0.0.0.0 --port 10701
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=wyoming-led
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user