From cae22004b1cef3b73e4ef3cd0247769c889cfd54 Mon Sep 17 00:00:00 2001 From: sirmrak Date: Tue, 19 May 2026 19:01:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D0=BD=D0=B0=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Qwen-Coder --- DEPLOY.md | 93 +++++ examples/example_led.py | 390 ++++++++++++++++++ examples/led_brightness_config.json | 2 +- examples/websocket-service/client.html | 132 ++++++ examples/websocket-service/test_client.py | 12 + examples/wyoming-led.py | 301 +++++++++----- install_systemd_services.sh | 115 ++++++ requirements.txt | 30 ++ scripts/satellite-python | 15 + service/wyoming-wakeword-vosk.service | 19 - {service => services}/wyoming-event.service | 14 +- .../wyoming-satelite-vosk.service | 6 +- services/wyoming-wakeword-vosk.service | 18 + start_led_service.sh | 32 ++ start_satellite.sh | 24 +- start_satellite_test.sh | 54 +++ wyoming-led.service | 20 + 17 files changed, 1139 insertions(+), 138 deletions(-) create mode 100644 DEPLOY.md create mode 100644 examples/example_led.py create mode 100644 examples/websocket-service/client.html create mode 100644 examples/websocket-service/test_client.py create mode 100755 install_systemd_services.sh create mode 100644 requirements.txt create mode 100755 scripts/satellite-python delete mode 100644 service/wyoming-wakeword-vosk.service rename {service => services}/wyoming-event.service (54%) rename {service => services}/wyoming-satelite-vosk.service (58%) create mode 100644 services/wyoming-wakeword-vosk.service create mode 100755 start_led_service.sh create mode 100755 start_satellite_test.sh create mode 100644 wyoming-led.service diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..620d61d --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,93 @@ +# Развёртывание Wyoming Satellite + +## Быстрый старт на новом сервере + +### 1. Клонирование репозитория +```bash +git clone /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` в каждом сервисе. diff --git a/examples/example_led.py b/examples/example_led.py new file mode 100644 index 0000000..b5e56d3 --- /dev/null +++ b/examples/example_led.py @@ -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' bool: + payload = list(struct.pack(b' 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() diff --git a/examples/led_brightness_config.json b/examples/led_brightness_config.json index f445f6d..146fc7d 100644 --- a/examples/led_brightness_config.json +++ b/examples/led_brightness_config.json @@ -6,5 +6,5 @@ "idle": 80, "paused": 40, "error": 255, - "global_multiplier": 0.6392156862745098 + "global_multiplier": 0.3 } \ No newline at end of file diff --git a/examples/websocket-service/client.html b/examples/websocket-service/client.html new file mode 100644 index 0000000..be7d0f2 --- /dev/null +++ b/examples/websocket-service/client.html @@ -0,0 +1,132 @@ + + + + + + Wyoming Satellite WebSocket + + + +
+
+

🛰️ Wyoming Satellite WebSocket

+
Connecting...
+
+ +
+
+ + + + diff --git a/examples/websocket-service/test_client.py b/examples/websocket-service/test_client.py new file mode 100644 index 0000000..6f5c767 --- /dev/null +++ b/examples/websocket-service/test_client.py @@ -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()) diff --git a/examples/wyoming-led.py b/examples/wyoming-led.py index 473882b..6cc2777 100644 --- a/examples/wyoming-led.py +++ b/examples/wyoming-led.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ -Wyoming LED Control - Простая надёжная версия для HA +Wyoming LED Control with MQTT Discovery for Home Assistant +Полная версия: Light + Sensor + 7 Number sliders """ import argparse @@ -41,8 +42,8 @@ MQTT_PASS = "mqtt" # Топики MQTT_TOPIC_COMMAND = "wyoming/led/command" MQTT_TOPIC_BRIGHTNESS_SET = "wyoming/led/brightness/set" -MQTT_TOPIC_STATE = "wyoming/led/state" # ON/OFF -MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state" # яркость +MQTT_TOPIC_STATE = "wyoming/led/state" +MQTT_TOPIC_BRIGHTNESS_STATE = "wyoming/led/brightness/state" MQTT_TOPIC_SATELLITE_STATE = "wyoming/satellite/state" MQTT_TOPIC_AVAILABILITY = "wyoming/led/availability" @@ -56,6 +57,7 @@ MQTT_DISCOVERY_DEVICE = { "model": "XVF3800", } +# Настройки яркости BRIGHTNESS_CONFIG = { "listening": 120, "wake": 200, "thinking": 150, "speaking": 140, "idle": 80, "paused": 40, "error": 255, @@ -74,10 +76,11 @@ def load_brightness_config(): if CONFIG_FILE.exists(): try: with open(CONFIG_FILE) as f: - BRIGHTNESS_CONFIG.update(json.load(f)) - logger.info("✅ Загружены настройки яркости") + saved = json.load(f) + BRIGHTNESS_CONFIG.update(saved) + logger.info(f"📂 Загружены настройки яркости из {CONFIG_FILE}") except Exception as e: - logger.warning(f"Ошибка загрузки config: {e}") + logger.warning(f"⚠️ Ошибка загрузки config: {e}") else: save_brightness_config() @@ -85,8 +88,9 @@ def save_brightness_config(): try: with open(CONFIG_FILE, "w") as f: json.dump(BRIGHTNESS_CONFIG, f, indent=2) + logger.info(f"💾 Сохранено в {CONFIG_FILE}: {BRIGHTNESS_CONFIG}") except Exception as e: - logger.error(f"Ошибка сохранения config: {e}") + logger.error(f"❌ Ошибка сохранения config: {e}") # ====================== 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) return True except Exception as e: - logger.error(f"LED error {command}: {e}") + logger.error(f"❌ LED command error {command}: {e}") return False def get_effective_brightness(state: str) -> int: 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): @@ -117,44 +122,56 @@ def set_led_state(state: str, skip_publish=False): run_xvf_command("LED_COLOR", ["0x00BCD4"]) run_xvf_command("LED_BRIGHTNESS", [brightness]) run_xvf_command("LED_SPEED", [2]) + logger.info(f"🔵 Listening: бирюзовое дыхание ({brightness})") elif state == "wake": run_xvf_command("LED_EFFECT", [1]) run_xvf_command("LED_COLOR", ["0xFF0000"]) run_xvf_command("LED_BRIGHTNESS", [brightness]) + logger.info(f"🔴 Wake: красный ({brightness})") elif state == "thinking": run_xvf_command("LED_EFFECT", [2]) run_xvf_command("LED_BRIGHTNESS", [brightness]) run_xvf_command("LED_SPEED", [4]) + logger.info(f"🌈 Thinking: радуга ({brightness})") elif state == "speaking": run_xvf_command("LED_EFFECT", [1]) run_xvf_command("LED_COLOR", ["0x4CAF50"]) run_xvf_command("LED_BRIGHTNESS", [brightness]) + logger.info(f"🟢 Speaking: зелёный ({brightness})") elif state == "idle": run_xvf_command("LED_EFFECT", [4]) run_xvf_command("LED_DOA_COLOR", ["0x303030", "0x00BCD4"]) run_xvf_command("LED_BRIGHTNESS", [brightness]) + logger.info(f"⚪ Idle: DoA ({brightness})") elif state == "paused": run_xvf_command("LED_EFFECT", [1]) run_xvf_command("LED_COLOR", ["0x404040"]) run_xvf_command("LED_BRIGHTNESS", [brightness]) + logger.info(f"⚫ Paused: серый ({brightness})") elif state == "error": run_xvf_command("LED_EFFECT", [1]) run_xvf_command("LED_COLOR", ["0xFF00FF"]) run_xvf_command("LED_BRIGHTNESS", [brightness]) + logger.info(f"🟣 Error: маджента ({brightness})") elif state == "off": run_xvf_command("LED_EFFECT", [0]) run_xvf_command("LED_BRIGHTNESS", [0]) + logger.info("⬛ Off: выключено") if not skip_publish: - publish_state(state, brightness) + publish_state(state) + publish_satellite_state(state) -# ====================== MQTT ====================== +# ====================== MQTT PUBLISH ====================== def publish_discovery_config(): - if not mqtt_client: + if not mqtt_client: return try: - discovery = { + logger.info("🔄 Публикация Discovery...") + + # Light + light_disc = { "name": "Wyoming LED", "unique_id": "wyoming_led_light", "state_topic": MQTT_TOPIC_STATE, @@ -166,121 +183,178 @@ def publish_discovery_config(): "payload_not_available": "offline", "payload_on": "ON", "payload_off": "OFF", - "brightness_scale": 100, # ← ИЗМЕНИЛИ НА 100 + "brightness_scale": 100, "optimistic": False, "qos": 1, "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( - f"{MQTT_DISCOVERY_PREFIX}/light/wyoming_led/config", - json.dumps(discovery), - retain=True, - qos=1 - ) - logger.info("✅ Discovery опубликован (scale = 100)") + # Sensor + sensor_disc = { + "name": "Wyoming Satellite State", + "unique_id": "wyoming_satellite_state", + "state_topic": MQTT_TOPIC_SATELLITE_STATE, + "availability_topic": MQTT_TOPIC_AVAILABILITY, + "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: - 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: 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" - - # Главное: публикуем текущий global_multiplier в процентах (0-100) 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_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): 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.subscribe(MQTT_TOPIC_COMMAND) 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() - - # Отправляем текущее состояние - brightness = get_effective_brightness(current_state) - publish_state(current_state, brightness) + publish_state(current_state) + publish_satellite_state(current_state) + publish_base_brightness_states() + logger.info(f"📡 Подписки: {MQTT_TOPIC_COMMAND}, {MQTT_TOPIC_BRIGHTNESS_SET}, 7 режимов") else: - logger.error(f"❌ MQTT ошибка: {rc}") + logger.error(f"❌ MQTT ошибка подключения: {rc}") def on_mqtt_message(client, userdata, msg): global ha_disabled, last_mqtt_brightness, current_state try: 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: - try: - ha_value = max(0, min(100, int(payload))) # 0-100 - new_multiplier = ha_value / 100.0 - - last_mqtt_brightness = ha_value - BRIGHTNESS_CONFIG["global_multiplier"] = new_multiplier + # Глобальная яркость + if topic == MQTT_TOPIC_BRIGHTNESS_SET: + ha_value = max(0, min(100, int(payload))) + 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() + + # Базовая яркость режима + 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() - - logger.info(f"🔆 HA установил яркость: {ha_value}% → multiplier = {new_multiplier:.2f}") - - if not ha_disabled: - # Применяем новую яркость к текущему состоянию - set_led_state(current_state, skip_publish=False) - - 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) + logger.info(f"🎛️ {mode}: {old_value} → {real_value} ({percent}%)") + logger.info(f"📤 Публикация {mode}/state = {percent}") + publish_base_brightness_states() + if current_state == mode and not ha_disabled: + logger.info(f"🔄 Применяем новую яркость к текущему состоянию {mode}") + set_led_state(mode) else: - # Если вдруг пришёл JSON (редкий случай) - try: - cmd = json.loads(payload) - ha_state = str(cmd.get("state", "")).upper() - bright = cmd.get("brightness") + logger.warning(f"⚠️ Неизвестный режим: {mode}") - if ha_state == "OFF": - ha_disabled = True - run_xvf_command("LED_EFFECT", [0]) - run_xvf_command("LED_BRIGHTNESS", [0]) - publish_state("off", 0) - elif ha_state == "ON": - ha_disabled = False - if bright is not None: - bright = max(0, min(255, int(bright))) - last_mqtt_brightness = bright - BRIGHTNESS_CONFIG["global_multiplier"] = bright / 255.0 - save_brightness_config() - set_led_state(current_state) - except: - logger.warning(f"Неизвестная команда: {payload}") + # ON / OFF + elif topic == MQTT_TOPIC_COMMAND: + ha_state = payload.upper() + if ha_state == "OFF": + ha_disabled = True + run_xvf_command("LED_EFFECT", [0]) + run_xvf_command("LED_BRIGHTNESS", [0]) + publish_state("off") + logger.info("⬛ LED OFF") + elif ha_state == "ON": + ha_disabled = False + set_led_state(current_state) + logger.info("✅ LED ON") 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(): global mqtt_client 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_message = on_mqtt_message mqtt_client.will_set(MQTT_TOPIC_AVAILABILITY, "offline", retain=True, qos=1) - mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60) mqtt_client.loop_start() +# ====================== HANDLER & MAIN ====================== class LedEventHandler(AsyncEventHandler): async def handle_event(self, event: Event) -> bool: if ha_disabled: return True et = event.type + logger.debug(f"🔍 Событие: {et}") if Detection.is_type(et): + logger.info("🎯 Detection → wake") set_led_state("wake") elif VoiceStarted.is_type(et): + logger.info("🎤 VoiceStarted → listening") set_led_state("listening") elif VoiceStopped.is_type(et): + logger.info("💭 VoiceStopped → thinking") set_led_state("thinking") elif et == "audio-start": + logger.info("🔊 audio-start → speaking") set_led_state("speaking") elif Played.is_type(et): + logger.info("✅ Played → idle") set_led_state("idle") elif et == "pause-satellite": + logger.info("⏸️ pause → paused") set_led_state("paused") elif SatelliteConnected.is_type(et): + logger.info("🔌 connected → idle") set_led_state("idle") 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(): - logger.info("🧹 Выключаем LED") - run_xvf_command("LED_EFFECT", [0]) - run_xvf_command("LED_BRIGHTNESS", [0]) + logger.info("🧹 Очистка: выключаем LED...") + try: + 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): - logger.info("🚀 Запуск Wyoming LED Service") + logger.info(f"🚀 Запуск на {args.host}:{args.port}") load_brightness_config() start_mqtt_client() run_xvf_command("VERSION") @@ -330,19 +422,18 @@ async def main(args): uri = f"tcp://{args.host}:{args.port}" server = AsyncServer.from_uri(uri) + logger.info(f"📡 Слушаем {uri}") await server.run(partial(LedEventHandler)) def signal_handler(sig, frame): - logger.info(f"🛑 Сигнал {sig}") + logger.info(f"\n🛑 Сигнал {sig}") cleanup_leds() if mqtt_client: - try: - mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True) - mqtt_client.loop_stop() - mqtt_client.disconnect() - except: - pass + mqtt_client.publish(MQTT_TOPIC_AVAILABILITY, "offline", retain=True) + mqtt_client.loop_stop() + mqtt_client.disconnect() + logger.info("📡 MQTT остановлен") sys.exit(0) diff --git a/install_systemd_services.sh b/install_systemd_services.sh new file mode 100755 index 0000000..8637b36 --- /dev/null +++ b/install_systemd_services.sh @@ -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 "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ea1686 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/satellite-python b/scripts/satellite-python new file mode 100755 index 0000000..2420933 --- /dev/null +++ b/scripts/satellite-python @@ -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" "$@" diff --git a/service/wyoming-wakeword-vosk.service b/service/wyoming-wakeword-vosk.service deleted file mode 100644 index d8a59ff..0000000 --- a/service/wyoming-wakeword-vosk.service +++ /dev/null @@ -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 \ No newline at end of file diff --git a/service/wyoming-event.service b/services/wyoming-event.service similarity index 54% rename from service/wyoming-event.service rename to services/wyoming-event.service index eab4c38..a7ad021 100644 --- a/service/wyoming-event.service +++ b/services/wyoming-event.service @@ -10,16 +10,16 @@ User=sirmrak Group=sirmrak # 🔹 Рабочая директория — где лежит wyoming-led.py -WorkingDirectory=/home/sirmrak/projects/Satellite/examples +WorkingDirectory=__SAT_BASE_DIR__/examples -# 🔹 Прямой запуск через python из виртуального окружения (без обёрток!) -ExecStart=/home/sirmrak/projects/.venv_satellite/bin/python3 /home/sirmrak/projects/Satellite/examples/wyoming-led.py --host 127.0.0.1 --port 10500 +# 🔹 Запуск через wrapper (путь подставляется при установке) +ExecStart=satellite-python __SAT_BASE_DIR__/examples/wyoming-led.py --host 127.0.0.1 --port 10500 # 🔹 Критически важно: передаём окружение -Environment="HOME=/home/sirmrak" -Environment="USER=sirmrak" -Environment="PATH=/home/sirmrak/projects/.venv_satellite/bin:/usr/local/bin:/usr/bin:/bin" -Environment="VIRTUAL_ENV=/home/sirmrak/projects/.venv_satellite" +Environment="HOME=__HOME_DIR__" +Environment="USER=__CURRENT_USER__" +Environment="PATH=__SAT_VENV__/bin:/usr/local/bin:/usr/bin:/bin" +Environment="VIRTUAL_ENV=__SAT_VENV__" # 🔹 Разрешаем доступ к устройству (на всякий случай) PrivateDevices=false diff --git a/service/wyoming-satelite-vosk.service b/services/wyoming-satelite-vosk.service similarity index 58% rename from service/wyoming-satelite-vosk.service rename to services/wyoming-satelite-vosk.service index bb53a27..2276be3 100644 --- a/service/wyoming-satelite-vosk.service +++ b/services/wyoming-satelite-vosk.service @@ -4,9 +4,9 @@ Requires=network.target [Service] Type=simple -User=sirmrak -WorkingDirectory=/home/sirmrak/projects/Satellite -ExecStart=/home/sirmrak/projects/Satellite/start_satellite.sh +User=__CURRENT_USER__ +WorkingDirectory=__SAT_BASE_DIR__ +ExecStart=__SAT_BASE_DIR__/start_satellite.sh Restart=on-failure RestartSec=5 diff --git a/services/wyoming-wakeword-vosk.service b/services/wyoming-wakeword-vosk.service new file mode 100644 index 0000000..82ac8c4 --- /dev/null +++ b/services/wyoming-wakeword-vosk.service @@ -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 \ No newline at end of file diff --git a/start_led_service.sh b/start_led_service.sh new file mode 100755 index 0000000..c03b459 --- /dev/null +++ b/start_led_service.sh @@ -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 diff --git a/start_satellite.sh b/start_satellite.sh index 3bdaa12..70696f0 100755 --- a/start_satellite.sh +++ b/start_satellite.sh @@ -5,15 +5,33 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) export PYTHONPATH="${SCRIPT_DIR}" -# Путь к Python-окружению -PY="/home/sirmrak/projects/.venv_satellite/bin/python" +# Путь к 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) MODE="${1:-wake}" BASE_ARGS=( --debug - --name 'Wyoming Pi Satellite' + --name 'Wyoming Pi Vosk Satellite' --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' --snd-command 'aplay -D plughw:CARD=Array,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw' diff --git a/start_satellite_test.sh b/start_satellite_test.sh new file mode 100755 index 0000000..93e8155 --- /dev/null +++ b/start_satellite_test.sh @@ -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[@]}" + + diff --git a/wyoming-led.service b/wyoming-led.service new file mode 100644 index 0000000..7e80dc7 --- /dev/null +++ b/wyoming-led.service @@ -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