first commit
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
"""Модуль 5: hotkeys — клавиши управления (событийные бэкенды).
|
||||
|
||||
Использование:
|
||||
|
||||
from modules.hotkeys import KeyEvents, get_backend
|
||||
|
||||
events = KeyEvents(on_talk_down=..., on_talk_up=..., on_stop=...,
|
||||
on_repeat=..., on_quit=...)
|
||||
backend = get_backend("console", events) # или "keys" (Windows / root-Linux)
|
||||
backend.start()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .base import KeyEvents
|
||||
from .console import ConsoleBackend
|
||||
from .keyboard_hook import KeyboardHookBackend
|
||||
|
||||
__all__ = ["KeyEvents", "ConsoleBackend", "KeyboardHookBackend", "get_backend"]
|
||||
|
||||
_BACKENDS = {
|
||||
ConsoleBackend.name: ConsoleBackend,
|
||||
KeyboardHookBackend.name: KeyboardHookBackend,
|
||||
}
|
||||
|
||||
|
||||
def get_backend(name: str, events: KeyEvents, **kwargs):
|
||||
"""Фабрика бэкендов клавиш."""
|
||||
if name == KeyboardHookBackend.name:
|
||||
# Клавиши можно переопределить через переменные окружения/.env:
|
||||
# HOTKEY_DIALOG (пробел-тумблер), HOTKEY_TALK, HOTKEY_STOP, HOTKEY_REPEAT
|
||||
kwargs.setdefault("dialog", os.environ.get("HOTKEY_DIALOG", "space"))
|
||||
kwargs.setdefault("talk", os.environ.get("HOTKEY_TALK", "num 0"))
|
||||
kwargs.setdefault("stop_key", os.environ.get("HOTKEY_STOP", "esc"))
|
||||
kwargs.setdefault("repeat", os.environ.get("HOTKEY_REPEAT", "enter"))
|
||||
try:
|
||||
backend_cls = _BACKENDS[name]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"Неизвестный бэкенд клавиш: {name!r}. Доступны: {sorted(_BACKENDS)}"
|
||||
) from None
|
||||
return backend_cls(events, **kwargs)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""События клавиш — контракт между бэкендом ввода и ассистентом.
|
||||
|
||||
Бэкенд (console / Windows-хук) только ПЕРЕДАЁТ события; вся логика
|
||||
реакции — в машине состояний (modules/assistant).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyEvents:
|
||||
"""Набор колбэков ассистента.
|
||||
|
||||
on_dialog_toggle — ПРОБЕЛ: начать диалог (куи «готов к диалогу») или
|
||||
завершить его (куи «до новых встреч»);
|
||||
on_talk_down — клавиша «Слушай» НАЖАТА (push-to-talk, резервный режим);
|
||||
on_talk_up — клавиша «Слушай» ОТПУЩЕНА;
|
||||
on_stop — «Замолчи»: заглушить речь / отменить;
|
||||
on_repeat — «Повтори»: последнюю фразу;
|
||||
on_quit — выход из программы.
|
||||
"""
|
||||
|
||||
on_talk_down: Callable[[], None]
|
||||
on_talk_up: Callable[[], None]
|
||||
on_stop: Callable[[], None]
|
||||
on_repeat: Callable[[], None]
|
||||
on_quit: Callable[[], None]
|
||||
on_dialog_toggle: Optional[Callable[[], None]] = None
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Консольный бэкенд клавиш — для разработки и теста в Linux.
|
||||
|
||||
Глобальный хук клавиатуры (библиотека keyboard) на Linux требует root,
|
||||
поэтому здесь клавиши имитируются вводом в терминале:
|
||||
|
||||
Enter — нажать/отпустить «Слушай» (первое нажатие = начать запись,
|
||||
второе = отпустить и обработать; аналог удержания кнопки);
|
||||
s + Enter — «Замолчи»;
|
||||
r + Enter — «Повтори»;
|
||||
q + Enter — выход.
|
||||
|
||||
На Windows-ПК отец получит настоящие глобальные клавиши (keyboard_hook.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from .base import KeyEvents
|
||||
|
||||
|
||||
class ConsoleBackend:
|
||||
name = "console"
|
||||
|
||||
def __init__(self, events: KeyEvents) -> None:
|
||||
self._events = events
|
||||
self._talk_held = False
|
||||
self._running = False
|
||||
|
||||
def start(self) -> None:
|
||||
import threading
|
||||
|
||||
self._running = True
|
||||
thread = threading.Thread(target=self._loop, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
# --- внутреннее ------------------------------------------------------
|
||||
def _loop(self) -> None:
|
||||
# Одиночные клавиши БЕЗ Enter (readchar): работает без root, когда
|
||||
# окно терминала в фокусе. Пробел = настоящий пробел, как на Windows.
|
||||
try:
|
||||
import readchar
|
||||
use_readchar = True
|
||||
except ImportError:
|
||||
use_readchar = False # фолбэк: строки + Enter (старое поведение)
|
||||
|
||||
if use_readchar:
|
||||
self._loop_readchar(readchar)
|
||||
else:
|
||||
self._loop_lines()
|
||||
|
||||
def _loop_readchar(self, readchar) -> None:
|
||||
while self._running:
|
||||
key = readchar.readkey()
|
||||
if key in ("\r", "\n"):
|
||||
continue # Enter игнорируем (не part of управления)
|
||||
if key == " ":
|
||||
self._events.on_dialog_toggle() # ПРОБЕЛ = тумблер диалога
|
||||
elif key == "s":
|
||||
self._events.on_stop()
|
||||
elif key == "r":
|
||||
self._events.on_repeat()
|
||||
elif key in ("q", "\x03"): # q или Ctrl+C
|
||||
self._events.on_quit()
|
||||
return
|
||||
# остальные клавиши игнорируются
|
||||
|
||||
def _loop_lines(self) -> None:
|
||||
"""Фолбэк: строки через Enter (если readchar не установлен)."""
|
||||
if self._events.on_dialog_toggle:
|
||||
print(" (readchar не установлен: пробел имитируй 'd' + Enter; "
|
||||
"pip install readchar — включит настоящий пробел)")
|
||||
while self._running:
|
||||
try:
|
||||
line = input()
|
||||
except EOFError: # stdin закрылся — выходим
|
||||
self._events.on_quit()
|
||||
return
|
||||
|
||||
cmd = line.strip().lower()
|
||||
if cmd == "q":
|
||||
self._events.on_quit()
|
||||
return
|
||||
if cmd in ("d", "space", "пробел"):
|
||||
if self._events.on_dialog_toggle:
|
||||
self._events.on_dialog_toggle()
|
||||
elif cmd == "s":
|
||||
self._events.on_stop()
|
||||
elif cmd == "r":
|
||||
self._events.on_repeat()
|
||||
elif cmd == "":
|
||||
# Enter без текста = нажатие/отпускание «Слушай» (push-to-talk)
|
||||
if self._talk_held:
|
||||
self._talk_held = False
|
||||
self._events.on_talk_up()
|
||||
else:
|
||||
self._talk_held = True
|
||||
self._events.on_talk_down()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Бэкенд глобальных клавиш на библиотеке keyboard — целевой для Windows.
|
||||
|
||||
Перехватывает клавиши ГЛОБАЛЬНО (окно может быть свёрнуто) — именно то,
|
||||
что нужно отцу. Требования/ограничения библиотеки keyboard:
|
||||
- Windows: работает без админ-прав;
|
||||
- Linux: требует root (поэтому в разработке используем console-бэкенд);
|
||||
- проект библиотеки не развивается с 2020 — если на Windows появятся
|
||||
проблемы, заменяем на pynput (интерфейс KeyEvents менять не придётся).
|
||||
|
||||
Клавиши задаются в .env (HOTKEY_TALK / HOTKEY_STOP / HOTKEY_REPEAT);
|
||||
по умолчанию: space / esc / enter. Имена — как в библиотеке keyboard
|
||||
(numpad-клавиши подберём при настройке на Windows, напр. «num 0», «num enter»).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import KeyEvents
|
||||
|
||||
|
||||
class KeyboardHookBackend:
|
||||
name = "keys"
|
||||
|
||||
def __init__(self, events: KeyEvents, talk: str = "num 0",
|
||||
stop_key: str = "esc", repeat: str = "enter",
|
||||
dialog: str = "space") -> None:
|
||||
self._events = events
|
||||
self._talk = talk
|
||||
self._stop_key = stop_key
|
||||
self._repeat = repeat
|
||||
self._dialog = dialog # ПРОБЕЛ: тумблер диалога
|
||||
self._started = False
|
||||
|
||||
def start(self) -> None:
|
||||
import keyboard # ленивый импорт: на Linux без root упадёт — см. console
|
||||
|
||||
if self._dialog and self._events.on_dialog_toggle:
|
||||
keyboard.add_hotkey(self._dialog, self._events.on_dialog_toggle, suppress=True)
|
||||
if self._talk:
|
||||
keyboard.on_press_key(self._talk, self._talk_press)
|
||||
keyboard.on_release_key(self._talk, self._talk_release)
|
||||
keyboard.add_hotkey(self._stop_key, self._events.on_stop, suppress=True)
|
||||
keyboard.add_hotkey(self._repeat, self._events.on_repeat, suppress=True)
|
||||
self._started = True
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._started:
|
||||
import keyboard
|
||||
keyboard.unhook_all()
|
||||
self._started = False
|
||||
|
||||
# --- внутреннее ------------------------------------------------------
|
||||
def _talk_press(self, event) -> None: # noqa: ANN001
|
||||
# Библиотека шлёт повторные down-события при удержании; лишние вызовы
|
||||
# безопасны: машина состояний игнорирует on_talk_down в состоянии listening.
|
||||
self._events.on_talk_down()
|
||||
|
||||
def _talk_release(self, event) -> None: # noqa: ANN001
|
||||
self._events.on_talk_up()
|
||||
Reference in New Issue
Block a user