Add voice buttons, Wi-Fi netconfig, and demo STT server
This commit is contained in:
+4
-1
@@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
find_package(Qt5 5.9 REQUIRED COMPONENTS Core Quick Qml Network)
|
||||
find_package(Qt5 5.9 REQUIRED COMPONENTS Core Quick Qml Network Multimedia)
|
||||
|
||||
set(SOURCES
|
||||
main.cpp
|
||||
@@ -17,6 +17,8 @@ set(SOURCES
|
||||
src/buttonsmodel.h
|
||||
src/buttoncontroller.cpp
|
||||
src/buttoncontroller.h
|
||||
src/audiorecorder.cpp
|
||||
src/audiorecorder.h
|
||||
src/responsecheck.cpp
|
||||
src/responsecheck.h
|
||||
src/settingscontainer.cpp
|
||||
@@ -41,6 +43,7 @@ target_link_libraries(ButtonTask PRIVATE
|
||||
Qt5::Quick
|
||||
Qt5::Qml
|
||||
Qt5::Network
|
||||
Qt5::Multimedia
|
||||
)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
@@ -6,18 +6,22 @@ companion Flask web configurator. Both share a single JSON config file.
|
||||
## Features
|
||||
|
||||
- Dynamic list of buttons (1..N), each fires HTTP GET/POST on tap
|
||||
- Button kinds: **press** (HTTP call) and **voice** (record PCM/WAV → classify-audio)
|
||||
- Voice gestures: hold-to-speak or click start/stop; lit result cancels via cancel URL
|
||||
- Per-button trigger mode: press-and-hold (DelayButton-style, animated progress
|
||||
ring, configurable hold duration) or plain click — set in both editors
|
||||
- Layout: grid (configurable column count) or list
|
||||
- Configurable background: animated gradient, solid color or image
|
||||
- Configurable feedback ring: color and width of the OK / error glow around buttons
|
||||
- In-app settings dialog (tabbed pages): CRUD buttons, drag-and-drop reorder,
|
||||
layout/background/feedback/password, Ethernet configuration and update info
|
||||
layout/background/feedback/password, Ethernet/Wi‑Fi configuration and update info
|
||||
- Web configurator (Flask blueprints + tabbed UI + SortableJS): same operations
|
||||
remotely, plus icon upload, Ethernet configuration and OTA updates
|
||||
- Ethernet configuration via NetworkManager (`nmcli`) from the web UI
|
||||
remotely, plus icon upload, Ethernet/Wi‑Fi configuration and OTA updates
|
||||
- Network configuration via NetworkManager (`nmcli`) — Ethernet and Wi‑Fi
|
||||
- OTA software update with automatic rollback on a failed launch (health-check)
|
||||
- Hot reload: app picks up config changes via `QFileSystemWatcher`
|
||||
- Demo server (`demo-server/`): Dockerized classify-audio + cancel + press log,
|
||||
OpenRouter STT/classify via optional SOCKS5
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -57,6 +61,7 @@ The Flask service reads the same paths.
|
||||
|
||||
```sh
|
||||
sudo apt install qtbase5-dev qtdeclarative5-dev \
|
||||
qtmultimedia5-dev libqt5multimedia5-plugins \
|
||||
qml-module-qtquick-controls2 qml-module-qtquick-layouts \
|
||||
qml-module-qtgraphicaleffects qml-module-qtqml-models2 \
|
||||
cmake build-essential
|
||||
|
||||
+45
-21
@@ -2,14 +2,14 @@
|
||||
"version": 2,
|
||||
"layout": {
|
||||
"mode": "list",
|
||||
"columns": 1,
|
||||
"columns": 2,
|
||||
"spacing": 10,
|
||||
"showLabels": true,
|
||||
"labelColor": "#ffffff"
|
||||
},
|
||||
"background": {
|
||||
"type": "gradient",
|
||||
"color1": "#00007b",
|
||||
"type": "solid",
|
||||
"color1": "#000000",
|
||||
"color2": "#7b007b",
|
||||
"animated": true,
|
||||
"imagePath": ""
|
||||
@@ -17,9 +17,9 @@
|
||||
"feedback": {
|
||||
"okColor": "#00cc44",
|
||||
"errorColor": "#cc2200",
|
||||
"okWidth": 3,
|
||||
"errorWidth": 2,
|
||||
"glowRadius": 28,
|
||||
"okWidth": 6,
|
||||
"errorWidth": 6,
|
||||
"glowRadius": 20,
|
||||
"holdColor": "#ffffff",
|
||||
"pendingColor": "#ffffff",
|
||||
"pendingWidth": 6
|
||||
@@ -33,18 +33,28 @@
|
||||
},
|
||||
"buttons": [
|
||||
{
|
||||
"id": "example-1",
|
||||
"label": "Пример",
|
||||
"iconPath": "cleaner1.png",
|
||||
"id": "cleaning-call",
|
||||
"label": "Вызов клининга",
|
||||
"kind": "press",
|
||||
"iconPath": "cleaner1Crop.png",
|
||||
"color": "#000000",
|
||||
"action": {
|
||||
"type": "http_get",
|
||||
"url": "http://localhost/btn?bid=01",
|
||||
"url": "https://button.grigowashere.ru/api/click/btn?bid=01*04*01",
|
||||
"headers": {},
|
||||
"body": ""
|
||||
"body": "",
|
||||
"timeoutMs": 15000
|
||||
},
|
||||
"latchReset": {
|
||||
"enabled": true,
|
||||
"resetUrl": "https://button.grigowashere.ru/api/click/btn?bid=01*04*00",
|
||||
"successMatch": "OK",
|
||||
"fireAndForget": true
|
||||
},
|
||||
"feedback": {
|
||||
"successText": "Отправлено",
|
||||
"successText": "Вызов отправлен",
|
||||
"errorText": "Ошибка",
|
||||
"pendingText": "...",
|
||||
"fadeMs": 5000
|
||||
},
|
||||
"trigger": {
|
||||
@@ -53,21 +63,35 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cbd6791cdcb74a58861554b30239c65e",
|
||||
"label": "dfg",
|
||||
"iconPath": "cogwheel1.png",
|
||||
"id": "voice-call",
|
||||
"label": "Аудио",
|
||||
"kind": "voice",
|
||||
"iconPath": "cleaner1Crop.png",
|
||||
"color": "#2a6f97",
|
||||
"action": {
|
||||
"type": "http_get",
|
||||
"url": "http://example.com",
|
||||
"headers": {},
|
||||
"body": ""
|
||||
"type": "http_post",
|
||||
"url": "https://button.grigowashere.ru/api/v1/classify-audio",
|
||||
"headers": {
|
||||
"X-API-Key": "97098109-3188-496b-b075-0e8d83c2bef5"
|
||||
},
|
||||
"body": "",
|
||||
"timeoutMs": 60000
|
||||
},
|
||||
"voice": {
|
||||
"sourceId": "panel-01",
|
||||
"cancelUrl": "https://button.grigowashere.ru/api/v1/cancel",
|
||||
"maxRecordMs": 30000
|
||||
},
|
||||
"feedback": {
|
||||
"successText": "",
|
||||
"errorText": "",
|
||||
"errorText": "Ошибка",
|
||||
"pendingText": "Слушаю...",
|
||||
"fadeMs": 5000
|
||||
},
|
||||
"color": "#ab26ab"
|
||||
"trigger": {
|
||||
"mode": "hold",
|
||||
"holdMs": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# API key expected from the button panel (X-API-Key header)
|
||||
API_KEY=97098109-3188-496b-b075-0e8d83c2bef5
|
||||
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-key
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# Whisper-compatible STT model (OpenRouter)
|
||||
# Better on short noisy RU: openai/gpt-4o-mini-transcribe or openai/gpt-4o-transcribe
|
||||
# Popular but not best here: openai/whisper-large-v3
|
||||
STT_MODEL=openai/gpt-4o-mini-transcribe
|
||||
# Text classification model
|
||||
CLASSIFY_MODEL=openai/gpt-4o-mini
|
||||
|
||||
# SOCKS5 proxy for OpenRouter (optional but recommended)
|
||||
# Example: socks5://user:pass@host:1080 or socks5://127.0.0.1:1080
|
||||
SOCKS5_PROXY=
|
||||
|
||||
# Bind
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
|
||||
# If true, skip OpenRouter and return a fake category from filename/heuristics
|
||||
MOCK_AI=false
|
||||
|
||||
# Profile denoise via post-roll tail. Default off — raw audio works better on panel mics.
|
||||
# Set DENOISE=true and voice.postRollMs=700 on the button to enable.
|
||||
DENOISE=false
|
||||
DENOISE_PROP=0.75
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY templates ./templates
|
||||
COPY static ./static
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=5000
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST} --port ${PORT}"]
|
||||
@@ -0,0 +1,84 @@
|
||||
# ButtonTask Demo Server
|
||||
|
||||
Демо-сервер для голосовых кнопок и обычных HTTP-нажатий.
|
||||
|
||||
## Возможности
|
||||
|
||||
- `POST /api/v1/classify-audio` — multipart `audio` + `source_id`, заголовок `X-API-Key`
|
||||
- `POST /api/v1/cancel` — JSON `{ "request_id", "source_id" }`, тот же API-ключ
|
||||
- `GET/POST /api/click/btn?bid=...` — обычные latch-нажатия (ответ `OK`)
|
||||
- `/ok`, `/fail`, `/cleaning`, `/echo` — вспомогательные эндпоинты
|
||||
- UI на `/` — лента событий и активные голосовые вызовы
|
||||
- Аудио сохраняется в volume и отдаётся с `/media/...`; в ленте — плеер в стиле голосового сообщения
|
||||
- Перед STT: конвертация в mono 16 kHz; опционально profile-denoise по хвосту (`DENOISE` + `postRollMs`)
|
||||
|
||||
STT и классификация идут через **OpenRouter**; при необходимости через **SOCKS5** (`SOCKS5_PROXY`).
|
||||
|
||||
## HTTPS через nginx + certbot
|
||||
|
||||
Контейнер слушает `127.0.0.1:5000` (или `:5000` на хосте). Пример:
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/button.grigowashere.ru
|
||||
server {
|
||||
listen 80;
|
||||
server_name button.grigowashere.ru;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# важно: не резать API-ключ с панели
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header X-API-Key $http_x_api_key;
|
||||
client_max_body_size 25m;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/button.grigowashere.ru /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d button.grigowashere.ru
|
||||
```
|
||||
|
||||
DNS `A`/`AAAA` для `button.grigowashere.ru` должен указывать на этот сервер.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# заполните OPENROUTER_API_KEY и при необходимости SOCKS5_PROXY
|
||||
# для офлайн-демо: MOCK_AI=true
|
||||
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Откройте http://localhost:5000/
|
||||
|
||||
### Curl
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/v1/classify-audio \
|
||||
-H "X-API-Key: 97098109-3188-496b-b075-0e8d83c2bef5" \
|
||||
-F "audio=@sample.wav" \
|
||||
-F "source_id=panel-01"
|
||||
|
||||
curl -X POST http://localhost:5000/api/v1/cancel \
|
||||
-H "X-API-Key: 97098109-3188-496b-b075-0e8d83c2bef5" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"request_id":"...","source_id":"panel-01"}'
|
||||
```
|
||||
|
||||
## Локально без Docker
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
MOCK_AI=true uvicorn app.main:app --host 0.0.0.0 --port 5000
|
||||
```
|
||||
@@ -0,0 +1,795 @@
|
||||
"""ButtonTask demo server — voice classify + press log + cancel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("demo-server")
|
||||
|
||||
UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "/tmp/buttontask-uploads"))
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "/tmp/buttontask-data"))
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CATEGORIES_FILE = DATA_DIR / "categories.json"
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
"Клининг",
|
||||
"АХО",
|
||||
"Охрана",
|
||||
"Канцелярия",
|
||||
"Техническая служба",
|
||||
]
|
||||
|
||||
SILENCE_MARKERS = (
|
||||
"",
|
||||
"(тишина)",
|
||||
"тишина",
|
||||
"...",
|
||||
".",
|
||||
"-",
|
||||
"silence",
|
||||
"[silence]",
|
||||
"(silence)",
|
||||
)
|
||||
|
||||
|
||||
def _load_categories() -> List[str]:
|
||||
if CATEGORIES_FILE.is_file():
|
||||
try:
|
||||
data = json.loads(CATEGORIES_FILE.read_text(encoding="utf-8"))
|
||||
cats = [str(c).strip() for c in (data.get("categories") or []) if str(c).strip()]
|
||||
if cats:
|
||||
return cats
|
||||
except (OSError, json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return list(DEFAULT_CATEGORIES)
|
||||
|
||||
|
||||
def _save_categories(cats: List[str]) -> List[str]:
|
||||
cleaned = []
|
||||
seen = set()
|
||||
for c in cats:
|
||||
name = str(c).strip()
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(name)
|
||||
if not cleaned:
|
||||
cleaned = list(DEFAULT_CATEGORIES)
|
||||
CATEGORIES_FILE.write_text(
|
||||
json.dumps({"categories": cleaned}, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _classify_system_prompt(cats: List[str]) -> str:
|
||||
listed = ", ".join(cats)
|
||||
return f"""Ты классификатор заявок с голосовой кнопки в здании.
|
||||
По тексту распознанной речи выбери ОДНУ категорию строго из списка:
|
||||
{listed}
|
||||
|
||||
Учти, что текст приходит из STT и может содержать опечатки/созвучия
|
||||
(например «скрипки»/«скрытки» вместо «скрепки», «клиненг» вместо «клининг»).
|
||||
Восстанавливай смысл по контексту офисной заявки.
|
||||
|
||||
Если речь пустая, неразборчивая, это шум/тишина, или ни одна категория не подходит —
|
||||
ответь ровно: UNMATCHED
|
||||
|
||||
Примеры:
|
||||
- "пролили кофе", "уберите мусор" → Клининг (если есть в списке)
|
||||
- "закончились скрепки", "принесите скрепки", "кончилась бумага" → Канцелярия / АХО (если есть)
|
||||
- "подозрительный человек" → Охрана (если есть)
|
||||
|
||||
Ответь ТОЛЬКО названием категории из списка или UNMATCHED, без пояснений."""
|
||||
|
||||
|
||||
# Whisper often mishears short office words on noisy mics.
|
||||
_TRANSCRIPT_FIXES = (
|
||||
(re.compile(r"\bскрипки\b", re.I), "скрепки"),
|
||||
(re.compile(r"\bскрытки\b", re.I), "скрепки"),
|
||||
(re.compile(r"\bскритки\b", re.I), "скрепки"),
|
||||
(re.compile(r"\bскрепке\b", re.I), "скрепки"),
|
||||
(re.compile(r"\bклиненг\b", re.I), "клининг"),
|
||||
(re.compile(r"\bклинингг\b", re.I), "клининг"),
|
||||
)
|
||||
|
||||
|
||||
def _fix_transcript(text: str) -> str:
|
||||
out = (text or "").strip()
|
||||
for rx, repl in _TRANSCRIPT_FIXES:
|
||||
out = rx.sub(repl, out)
|
||||
return out.strip()
|
||||
|
||||
|
||||
# Bias Whisper toward office Russian vocabulary (reduces «скрипки» etc.).
|
||||
STT_PROMPT = (
|
||||
"Голосовая заявка с панели в здании на русском. "
|
||||
"Возможные темы: клининг, уборка, скрепки, бумага, канцелярия, АХО, охрана, "
|
||||
"техническая служба, принесите, закончились, пролили, уберите."
|
||||
)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore")
|
||||
|
||||
api_key: str = "97098109-3188-496b-b075-0e8d83c2bef5"
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
# gpt-4o-mini-transcribe usually beats vanilla Whisper on noisy short RU clips
|
||||
stt_model: str = "openai/gpt-4o-mini-transcribe"
|
||||
classify_model: str = "openai/gpt-4o-mini"
|
||||
socks5_proxy: str = ""
|
||||
mock_ai: bool = False
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 5000
|
||||
# Profile denoise via post-roll silence tail. Off by default — raw audio STT better here.
|
||||
denoise: bool = False
|
||||
denoise_nf: float = -18.0
|
||||
denoise_prop: float = 0.75
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# In-memory event log and active voice calls
|
||||
_events: Deque[Dict[str, Any]] = deque(maxlen=200)
|
||||
_active: Dict[str, Dict[str, Any]] = {} # request_id -> call
|
||||
_lock = asyncio.Lock()
|
||||
_categories: List[str] = _load_categories()
|
||||
|
||||
app = FastAPI(title="ButtonTask Demo Server", version="1.0.0")
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
if static_dir.is_dir():
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
app.mount("/media", StaticFiles(directory=str(UPLOAD_DIR)), name="media")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _audio_url(filename: str) -> str:
|
||||
return f"/media/{filename}"
|
||||
|
||||
|
||||
def _push_event(kind: str, **payload: Any) -> Dict[str, Any]:
|
||||
ev = {"id": str(uuid.uuid4()), "ts": _now_iso(), "kind": kind, **payload}
|
||||
_events.appendleft(ev)
|
||||
log.info("event %s %s", kind, {k: v for k, v in payload.items() if k not in ("headers",)})
|
||||
return ev
|
||||
|
||||
|
||||
def _is_silence(text: str) -> bool:
|
||||
t = (text or "").strip().lower()
|
||||
if t in SILENCE_MARKERS:
|
||||
return True
|
||||
# very short / non-letter noise
|
||||
letters = re.sub(r"[^\wа-яё]", "", t, flags=re.I)
|
||||
return len(letters) < 2
|
||||
|
||||
|
||||
def _normalize_category(raw: str, cats: List[str]) -> Optional[str]:
|
||||
cleaned = (raw or "").strip().strip('"').strip("'")
|
||||
if not cleaned or cleaned.upper() == "UNMATCHED":
|
||||
return None
|
||||
for cat in cats:
|
||||
if cat.lower() == cleaned.lower():
|
||||
return cat
|
||||
for cat in cats:
|
||||
if cat.lower() in cleaned.lower() or cleaned.lower() in cat.lower():
|
||||
return cat
|
||||
return None
|
||||
|
||||
|
||||
def _mock_transcript(path: Path) -> str:
|
||||
name = path.name.lower()
|
||||
if "silent" in name or "тишин" in name:
|
||||
return ""
|
||||
if "coffee" in name or "кофе" in name:
|
||||
return "пролили кофе"
|
||||
if "paper" in name or "бумаг" in name:
|
||||
return "кончилась бумага"
|
||||
return "пролили кофе в коридоре"
|
||||
|
||||
|
||||
def _mock_classify(text: str, cats: List[str]) -> Optional[str]:
|
||||
if _is_silence(text):
|
||||
return None
|
||||
low = (text or "").lower()
|
||||
rules = [
|
||||
(("кофе", "мусор", "гряз", "убор", "пролил"), "Клининг"),
|
||||
(("бумаг", "ручк", "картридж", "канцеляр"), "Канцелярия"),
|
||||
(("охран", "дверь", "подозрит"), "Охрана"),
|
||||
(("розетк", "кран", "сломан", "не работает"), "Техническая служба"),
|
||||
(("ахо", "хозяйств"), "АХО"),
|
||||
]
|
||||
for words, cat in rules:
|
||||
if any(w in low for w in words) and cat in cats:
|
||||
return cat
|
||||
# first category containing substring match from text tokens — else unmatched
|
||||
return None
|
||||
|
||||
|
||||
def _extract_api_key(request: Request, header_key: Optional[str] = None) -> Optional[str]:
|
||||
"""Prefer X-API-Key header; fall back to Authorization / query (debug)."""
|
||||
if header_key:
|
||||
return header_key.strip() or None
|
||||
# Case-insensitive header lookup (proxies / Qt variants)
|
||||
for name, val in request.headers.items():
|
||||
if name.lower() == "x-api-key" and val:
|
||||
return val.strip()
|
||||
if name.lower() == "authorization" and val.lower().startswith("bearer "):
|
||||
return val[7:].strip()
|
||||
q = request.query_params.get("api_key")
|
||||
return q.strip() if q else None
|
||||
|
||||
|
||||
def _check_api_key(x_api_key: Optional[str]) -> None:
|
||||
expected = (settings.api_key or "").strip()
|
||||
got = (x_api_key or "").strip()
|
||||
if not got or got != expected:
|
||||
log.warning(
|
||||
"API auth failed: header_present=%s got_len=%s expected_len=%s got_prefix=%r",
|
||||
bool(got),
|
||||
len(got),
|
||||
len(expected),
|
||||
(got[:12] + "…") if got else "",
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key")
|
||||
|
||||
|
||||
def _httpx_client() -> httpx.AsyncClient:
|
||||
kwargs: Dict[str, Any] = {"timeout": 120.0}
|
||||
proxy = (settings.socks5_proxy or "").strip()
|
||||
if proxy:
|
||||
kwargs["proxy"] = proxy
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
|
||||
def _write_wav_mono_s16(path: Path, audio: Any, rate: int) -> None:
|
||||
import wave
|
||||
import numpy as np
|
||||
|
||||
pcm = np.clip(audio, -32768, 32767).astype(np.int16)
|
||||
with wave.open(str(path), "wb") as out:
|
||||
out.setnchannels(1)
|
||||
out.setsampwidth(2)
|
||||
out.setframerate(rate)
|
||||
out.writeframes(pcm.tobytes())
|
||||
|
||||
|
||||
def _ffmpeg_to_mono16k(src: Path, dst: Path) -> bool:
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
return False
|
||||
# Light band-limit only — no blind denoise/gain (that hurt clarity).
|
||||
af = "highpass=f=80,lowpass=f=7500"
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
ffmpeg, "-y", "-i", str(src),
|
||||
"-af", af,
|
||||
"-ac", "1", "-ar", "16000", "-sample_fmt", "s16",
|
||||
str(dst),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=90,
|
||||
)
|
||||
return dst.is_file() and dst.stat().st_size > 44
|
||||
except Exception as exc:
|
||||
log.warning("ffmpeg convert failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _preprocess_audio(src: Path, noise_tail_ms: int = 0) -> Path:
|
||||
"""Convert to 16 kHz mono. Optional: trim/use silence tail for profile denoise."""
|
||||
mid = src.with_name(f"{src.stem}_ff.wav")
|
||||
dst = src.with_name(f"{src.stem}_clean.wav")
|
||||
|
||||
if not _ffmpeg_to_mono16k(src, mid):
|
||||
if src.suffix.lower() == ".wav":
|
||||
mid = src
|
||||
else:
|
||||
return src
|
||||
|
||||
tail_ms = max(0, int(noise_tail_ms or 0))
|
||||
|
||||
# Default path: convert only (best STT on this hardware). Optionally trim tail.
|
||||
if not settings.denoise:
|
||||
try:
|
||||
import wave
|
||||
import numpy as np
|
||||
|
||||
if tail_ms > 0:
|
||||
with wave.open(str(mid), "rb") as w:
|
||||
if w.getsampwidth() == 2:
|
||||
rate = w.getframerate()
|
||||
nch = w.getnchannels()
|
||||
frames = w.readframes(w.getnframes())
|
||||
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
|
||||
if nch > 1:
|
||||
audio = audio.reshape(-1, nch).mean(axis=1)
|
||||
cut = int(rate * (tail_ms / 1000.0))
|
||||
if cut > 0 and audio.size > cut + int(rate * 0.2):
|
||||
_write_wav_mono_s16(dst, audio[:-cut], rate)
|
||||
if mid != src:
|
||||
try:
|
||||
mid.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
log.info("audio preprocess: convert+trim tail=%sms → %s", tail_ms, dst.name)
|
||||
return dst
|
||||
except Exception as exc:
|
||||
log.warning("tail trim failed: %s", exc)
|
||||
|
||||
if mid != dst:
|
||||
try:
|
||||
if mid != src:
|
||||
mid.replace(dst)
|
||||
else:
|
||||
import shutil as _sh
|
||||
_sh.copy2(mid, dst)
|
||||
except OSError:
|
||||
return mid
|
||||
log.info("audio preprocess: convert only → %s", dst.name)
|
||||
return dst
|
||||
|
||||
# denoise=true: subtract noise profile from post-roll tail, then trim
|
||||
try:
|
||||
import numpy as np
|
||||
import noisereduce as nr
|
||||
import wave
|
||||
|
||||
with wave.open(str(mid), "rb") as w:
|
||||
if w.getsampwidth() != 2:
|
||||
mid.replace(dst)
|
||||
return dst
|
||||
rate = w.getframerate()
|
||||
nch = w.getnchannels()
|
||||
frames = w.readframes(w.getnframes())
|
||||
|
||||
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
|
||||
if nch > 1:
|
||||
audio = audio.reshape(-1, nch).mean(axis=1)
|
||||
|
||||
tail_samples = int(rate * (tail_ms / 1000.0))
|
||||
min_speech = int(rate * 0.2)
|
||||
if tail_samples < int(rate * 0.15) or audio.size < (tail_samples + min_speech):
|
||||
log.info("audio preprocess: no usable tail, convert only")
|
||||
mid.replace(dst)
|
||||
return dst
|
||||
|
||||
speech = audio[:-tail_samples]
|
||||
noise = audio[-tail_samples:]
|
||||
prop = max(0.0, min(0.95, float(settings.denoise_prop)))
|
||||
reduced = nr.reduce_noise(
|
||||
y=speech,
|
||||
sr=rate,
|
||||
y_noise=noise,
|
||||
stationary=True,
|
||||
prop_decrease=prop,
|
||||
)
|
||||
peak = float(np.max(np.abs(reduced))) if reduced.size else 0.0
|
||||
if peak > 200:
|
||||
gain = min(6.0, (0.7 * 32767.0) / peak)
|
||||
reduced = reduced * gain
|
||||
|
||||
_write_wav_mono_s16(dst, reduced, rate)
|
||||
try:
|
||||
if mid != src:
|
||||
mid.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
log.info(
|
||||
"audio preprocess: profile denoise ok → %s (tail=%sms prop=%s)",
|
||||
dst.name,
|
||||
tail_ms,
|
||||
prop,
|
||||
)
|
||||
return dst if dst.is_file() else src
|
||||
except Exception as exc:
|
||||
log.warning("profile denoise failed: %s", exc)
|
||||
try:
|
||||
if mid.exists():
|
||||
mid.replace(dst)
|
||||
return dst
|
||||
except OSError:
|
||||
pass
|
||||
return mid if mid.exists() else src
|
||||
|
||||
|
||||
def _to_wav(src: Path) -> Path:
|
||||
"""Ensure mono 16-bit PCM WAV (already cleaned if *_clean.wav)."""
|
||||
if src.name.endswith("_clean.wav"):
|
||||
return src
|
||||
return _preprocess_audio(src, noise_tail_ms=0)
|
||||
|
||||
|
||||
async def _stt_openrouter(audio_path: Path) -> str:
|
||||
if settings.mock_ai or not settings.openrouter_api_key:
|
||||
return _fix_transcript(_mock_transcript(audio_path))
|
||||
|
||||
wav = await asyncio.to_thread(_to_wav, audio_path)
|
||||
url = f"{settings.openrouter_base_url.rstrip('/')}/audio/transcriptions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"HTTP-Referer": "https://buttontask.local",
|
||||
"X-Title": "ButtonTask Demo",
|
||||
}
|
||||
async with _httpx_client() as client:
|
||||
with wav.open("rb") as f:
|
||||
files = {"file": (wav.name, f, "audio/wav")}
|
||||
data = {
|
||||
"model": settings.stt_model,
|
||||
"language": "ru",
|
||||
"prompt": STT_PROMPT,
|
||||
"temperature": "0",
|
||||
}
|
||||
resp = await client.post(url, headers=headers, files=files, data=data)
|
||||
if resp.status_code >= 400:
|
||||
# Fallback: chat completions with base64 audio (some OpenRouter models)
|
||||
text = await _stt_via_chat(wav)
|
||||
if text:
|
||||
return _fix_transcript(text)
|
||||
raise HTTPException(status_code=502, detail=f"STT failed: {resp.status_code} {resp.text[:300]}")
|
||||
payload = resp.json()
|
||||
return _fix_transcript((payload.get("text") or "").strip())
|
||||
|
||||
|
||||
async def _stt_via_chat(audio_path: Path) -> str:
|
||||
"""Fallback STT through chat completions with input_audio."""
|
||||
raw = audio_path.read_bytes()
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
fmt = "wav" if audio_path.suffix.lower() == ".wav" else audio_path.suffix.lstrip(".") or "wav"
|
||||
url = f"{settings.openrouter_base_url.rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://buttontask.local",
|
||||
"X-Title": "ButtonTask Demo",
|
||||
}
|
||||
body = {
|
||||
"model": settings.stt_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Transcribe this audio to Russian text. Reply with only the transcript."},
|
||||
{"type": "input_audio", "input_audio": {"data": b64, "format": fmt}},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
try:
|
||||
async with _httpx_client() as client:
|
||||
resp = await client.post(url, headers=headers, json=body)
|
||||
if resp.status_code >= 400:
|
||||
log.warning("chat STT failed: %s %s", resp.status_code, resp.text[:200])
|
||||
return ""
|
||||
data = resp.json()
|
||||
return (data["choices"][0]["message"]["content"] or "").strip()
|
||||
except Exception as exc:
|
||||
log.warning("chat STT exception: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
async def _classify_openrouter(text: str) -> Optional[str]:
|
||||
cats = list(_categories)
|
||||
if settings.mock_ai or not settings.openrouter_api_key:
|
||||
return _mock_classify(text, cats)
|
||||
|
||||
url = f"{settings.openrouter_base_url.rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://buttontask.local",
|
||||
"X-Title": "ButtonTask Demo",
|
||||
}
|
||||
body = {
|
||||
"model": settings.classify_model,
|
||||
"temperature": 0,
|
||||
"messages": [
|
||||
{"role": "system", "content": _classify_system_prompt(cats)},
|
||||
{"role": "user", "content": text or "(тишина)"},
|
||||
],
|
||||
}
|
||||
async with _httpx_client() as client:
|
||||
resp = await client.post(url, headers=headers, json=body)
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail=f"Classify failed: {resp.status_code} {resp.text[:300]}")
|
||||
data = resp.json()
|
||||
raw = (data["choices"][0]["message"]["content"] or "").strip()
|
||||
return _normalize_category(raw, cats)
|
||||
|
||||
|
||||
# ── Routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{
|
||||
"request": request,
|
||||
"events": list(_events)[:50],
|
||||
"active": list(_active.values()),
|
||||
"categories": list(_categories),
|
||||
"mock_ai": settings.mock_ai or not settings.openrouter_api_key,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/events")
|
||||
async def api_events(limit: int = 50):
|
||||
return {
|
||||
"events": list(_events)[: max(1, min(limit, 200))],
|
||||
"active": list(_active.values()),
|
||||
"categories": list(_categories),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/categories")
|
||||
async def get_categories():
|
||||
return {"categories": list(_categories)}
|
||||
|
||||
|
||||
@app.put("/api/categories")
|
||||
async def put_categories(request: Request):
|
||||
data = await request.json()
|
||||
cats = data.get("categories")
|
||||
if not isinstance(cats, list):
|
||||
raise HTTPException(status_code=400, detail="categories must be an array of strings")
|
||||
global _categories
|
||||
_categories = _save_categories(cats)
|
||||
_push_event("categories_updated", categories=list(_categories))
|
||||
return {"ok": True, "categories": list(_categories)}
|
||||
|
||||
|
||||
@app.post("/api/v1/classify-audio")
|
||||
async def classify_audio(
|
||||
request: Request,
|
||||
audio: UploadFile = File(...),
|
||||
source_id: str = Form(...),
|
||||
noise_tail_ms: int = Form(0),
|
||||
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
||||
):
|
||||
_check_api_key(_extract_api_key(request, x_api_key))
|
||||
if not audio.filename and not audio.content_type:
|
||||
raise HTTPException(status_code=400, detail="No audio file provided")
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
suffix = Path(audio.filename or "audio.wav").suffix or ".wav"
|
||||
if suffix.lower() not in (".wav", ".webm", ".ogg", ".mp3", ".m4a", ".flac", ".pcm", ".raw"):
|
||||
suffix = ".wav"
|
||||
audio_name = f"{request_id}{suffix}"
|
||||
dest = UPLOAD_DIR / audio_name
|
||||
content = await audio.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Empty audio file")
|
||||
dest.write_bytes(content)
|
||||
clean = await asyncio.to_thread(_preprocess_audio, dest, int(noise_tail_ms or 0))
|
||||
audio_url = _audio_url(clean.name)
|
||||
|
||||
try:
|
||||
transcript = await _stt_openrouter(clean)
|
||||
if _is_silence(transcript):
|
||||
category = None
|
||||
reason = "silence"
|
||||
else:
|
||||
category = await _classify_openrouter(transcript)
|
||||
reason = "ok" if category else "unmatched"
|
||||
except HTTPException:
|
||||
_push_event(
|
||||
"voice_error",
|
||||
source_id=source_id,
|
||||
request_id=request_id,
|
||||
error="STT/classify failed",
|
||||
ip=request.client.host if request.client else "",
|
||||
audio_url=audio_url,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.exception("classify failed")
|
||||
_push_event(
|
||||
"voice_error",
|
||||
source_id=source_id,
|
||||
request_id=request_id,
|
||||
error=str(exc),
|
||||
ip=request.client.host if request.client else "",
|
||||
audio_url=audio_url,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": str(exc)},
|
||||
)
|
||||
|
||||
ts = _now_iso()
|
||||
ip = request.client.host if request.client else ""
|
||||
|
||||
# Warning: silence / no matching category — button shows warning and does not latch
|
||||
if category is None:
|
||||
message = "Тишина" if reason == "silence" else "Не распознано"
|
||||
_push_event(
|
||||
"voice_warning",
|
||||
source_id=source_id,
|
||||
request_id=request_id,
|
||||
category=message,
|
||||
request_text=transcript or "",
|
||||
reason=reason,
|
||||
ip=ip,
|
||||
audio_url=audio_url,
|
||||
)
|
||||
return {
|
||||
"status": "warning",
|
||||
"request_id": request_id,
|
||||
"category": message,
|
||||
"message": message,
|
||||
"timestamp": ts,
|
||||
"source_id": source_id,
|
||||
"request_text": transcript or "",
|
||||
"needs_cancel": False,
|
||||
"reason": reason,
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
|
||||
call = {
|
||||
"request_id": request_id,
|
||||
"source_id": source_id,
|
||||
"category": category,
|
||||
"request_text": transcript,
|
||||
"timestamp": ts,
|
||||
"status": "active",
|
||||
"ip": ip,
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
async with _lock:
|
||||
_active[request_id] = call
|
||||
|
||||
_push_event(
|
||||
"voice",
|
||||
source_id=source_id,
|
||||
request_id=request_id,
|
||||
category=category,
|
||||
request_text=transcript,
|
||||
ip=ip,
|
||||
audio_url=audio_url,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"request_id": request_id,
|
||||
"category": category,
|
||||
"timestamp": ts,
|
||||
"source_id": source_id,
|
||||
"request_text": transcript,
|
||||
"needs_cancel": True,
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/cancel")
|
||||
async def cancel_call(
|
||||
request: Request,
|
||||
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
||||
):
|
||||
_check_api_key(_extract_api_key(request, x_api_key))
|
||||
body: Dict[str, Any] = {}
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
if "application/json" in ctype:
|
||||
body = await request.json()
|
||||
elif "application/x-www-form-urlencoded" in ctype or "multipart/form-data" in ctype:
|
||||
form = await request.form()
|
||||
body = {k: form.get(k) for k in form.keys()}
|
||||
else:
|
||||
raw = (await request.body()).decode("utf-8", errors="replace").strip()
|
||||
if raw:
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
body = {}
|
||||
|
||||
request_id = (body.get("request_id") or "").strip()
|
||||
source_id = (body.get("source_id") or "").strip()
|
||||
|
||||
cancelled = None
|
||||
async with _lock:
|
||||
if request_id and request_id in _active:
|
||||
cancelled = _active.pop(request_id)
|
||||
elif source_id:
|
||||
for rid, call in list(_active.items()):
|
||||
if call.get("source_id") == source_id:
|
||||
cancelled = _active.pop(rid)
|
||||
break
|
||||
|
||||
if not cancelled:
|
||||
_push_event("cancel_miss", request_id=request_id, source_id=source_id)
|
||||
return {"status": "ok", "cancelled": False, "message": "no active call"}
|
||||
|
||||
cancelled["status"] = "cancelled"
|
||||
_push_event(
|
||||
"cancel",
|
||||
request_id=cancelled.get("request_id"),
|
||||
source_id=cancelled.get("source_id"),
|
||||
category=cancelled.get("category"),
|
||||
)
|
||||
return {"status": "ok", "cancelled": True, "request_id": cancelled.get("request_id")}
|
||||
|
||||
|
||||
def _log_press(request: Request, path: str, method: str, qs: str, body: str = "") -> Dict[str, Any]:
|
||||
return _push_event(
|
||||
"press",
|
||||
path=path,
|
||||
method=method,
|
||||
query=qs,
|
||||
body=body[:500] if body else "",
|
||||
ip=request.client.host if request.client else "",
|
||||
)
|
||||
|
||||
|
||||
@app.api_route("/api/click/btn", methods=["GET", "POST"])
|
||||
async def click_btn(request: Request):
|
||||
qs = str(request.url.query)
|
||||
body = ""
|
||||
if request.method == "POST":
|
||||
body = (await request.body()).decode("utf-8", errors="replace")
|
||||
bid = request.query_params.get("bid", "")
|
||||
action = "reset" if bid.endswith("*00") else ("trigger" if bid.endswith("*01") else "unknown")
|
||||
_log_press(request, "/api/click/btn", request.method, qs, body)
|
||||
_push_event("latch", bid=bid, action=action, ip=request.client.host if request.client else "")
|
||||
return PlainTextResponse("OK")
|
||||
|
||||
|
||||
@app.get("/ok")
|
||||
async def ok_endpoint(request: Request):
|
||||
_log_press(request, "/ok", "GET", "")
|
||||
return PlainTextResponse("OK\n")
|
||||
|
||||
|
||||
@app.get("/fail")
|
||||
async def fail_endpoint(request: Request):
|
||||
_log_press(request, "/fail", "GET", "")
|
||||
return PlainTextResponse("FAIL\n", status_code=500)
|
||||
|
||||
|
||||
@app.get("/cleaning")
|
||||
async def cleaning(request: Request, result: str = "ok"):
|
||||
_log_press(request, "/cleaning", "GET", f"result={result}")
|
||||
return {"success": result, "service": "cleaning", "message": f"cleaning:{result}"}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"ok": True, "mock_ai": settings.mock_ai or not settings.openrouter_api_key}
|
||||
|
||||
|
||||
@app.get("/echo")
|
||||
async def echo(request: Request):
|
||||
_log_press(request, "/echo", "GET", str(request.url.query))
|
||||
return {"query": dict(request.query_params), "headers": dict(request.headers)}
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
demo-server:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-5000}:5000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=5000
|
||||
volumes:
|
||||
- demo_uploads:/tmp/buttontask-uploads
|
||||
- demo_data:/tmp/buttontask-data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
demo_uploads:
|
||||
demo_data:
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
python-multipart==0.0.20
|
||||
httpx[socks]==0.28.1
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
jinja2==3.1.5
|
||||
aiofiles==24.1.0
|
||||
numpy==2.2.2
|
||||
noisereduce==2.0.1
|
||||
scipy==1.15.1
|
||||
@@ -0,0 +1,268 @@
|
||||
let categories = Array.isArray(window.__INITIAL_CATEGORIES__)
|
||||
? window.__INITIAL_CATEGORIES__.slice()
|
||||
: [];
|
||||
|
||||
let lastFeedSig = "";
|
||||
let lastActiveSig = "";
|
||||
let activeAudio = null;
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtTime(sec) {
|
||||
if (!Number.isFinite(sec) || sec < 0) return "0:00";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function voiceBubbleHtml(url, id) {
|
||||
const bars = Array.from({ length: 28 }, (_, i) => {
|
||||
const h = 28 + Math.round(42 * Math.abs(Math.sin(i * 0.55 + (id || "").length)));
|
||||
return `<i style="--h:${h}%"></i>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="voice-bubble" data-src="${esc(url)}" data-id="${esc(id || "")}">
|
||||
<button type="button" class="voice-play" aria-label="Воспроизвести">
|
||||
<svg class="icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>
|
||||
</button>
|
||||
<div class="voice-wave" aria-hidden="true">${bars}</div>
|
||||
<span class="voice-time">0:00</span>
|
||||
<audio preload="metadata" src="${esc(url)}"></audio>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bindVoiceBubbles(root) {
|
||||
root.querySelectorAll(".voice-bubble").forEach(bubble => {
|
||||
if (bubble.dataset.bound) return;
|
||||
bubble.dataset.bound = "1";
|
||||
const wave = bubble.querySelector(".voice-wave");
|
||||
if (wave && !wave.children.length) {
|
||||
const id = bubble.dataset.id || "";
|
||||
wave.innerHTML = Array.from({ length: 28 }, (_, i) => {
|
||||
const h = 28 + Math.round(42 * Math.abs(Math.sin(i * 0.55 + id.length)));
|
||||
return `<i style="--h:${h}%"></i>`;
|
||||
}).join("");
|
||||
}
|
||||
const audio = bubble.querySelector("audio");
|
||||
const btn = bubble.querySelector(".voice-play");
|
||||
const timeEl = bubble.querySelector(".voice-time");
|
||||
if (!audio || !btn) return;
|
||||
|
||||
const stopOthers = () => {
|
||||
if (activeAudio && activeAudio !== audio) {
|
||||
activeAudio.pause();
|
||||
const other = activeAudio.closest(".voice-bubble");
|
||||
other?.classList.remove("playing");
|
||||
}
|
||||
};
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
if (audio.paused) {
|
||||
stopOthers();
|
||||
audio.play().catch(() => {});
|
||||
bubble.classList.add("playing");
|
||||
activeAudio = audio;
|
||||
} else {
|
||||
audio.pause();
|
||||
bubble.classList.remove("playing");
|
||||
}
|
||||
});
|
||||
|
||||
const bars = bubble.querySelectorAll(".voice-wave i");
|
||||
const paintProgress = (p) => {
|
||||
bubble.style.setProperty("--progress", String(p));
|
||||
bars.forEach((bar, i) => {
|
||||
bar.classList.toggle("filled", bars.length ? i / bars.length < p : false);
|
||||
});
|
||||
};
|
||||
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
const t = audio.duration && Number.isFinite(audio.duration)
|
||||
? Math.max(0, audio.duration - audio.currentTime)
|
||||
: audio.currentTime;
|
||||
timeEl.textContent = fmtTime(t);
|
||||
const p = audio.duration ? audio.currentTime / audio.duration : 0;
|
||||
paintProgress(Math.min(1, Math.max(0, p)));
|
||||
});
|
||||
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
timeEl.textContent = fmtTime(audio.duration);
|
||||
});
|
||||
|
||||
audio.addEventListener("ended", () => {
|
||||
bubble.classList.remove("playing");
|
||||
paintProgress(0);
|
||||
timeEl.textContent = fmtTime(audio.duration);
|
||||
if (activeAudio === audio) activeAudio = null;
|
||||
});
|
||||
|
||||
audio.addEventListener("pause", () => {
|
||||
if (!audio.ended) bubble.classList.remove("playing");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderCategories() {
|
||||
const list = document.getElementById("cat-list");
|
||||
if (!list) return;
|
||||
if (!categories.length) {
|
||||
list.innerHTML = '<li class="cat-empty muted">Список пуст — добавьте категорию</li>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = categories.map((name, i) => `
|
||||
<li class="cat-item" data-index="${i}">
|
||||
<span class="cat-order">${i + 1}</span>
|
||||
<input type="text" class="cat-name" value="${esc(name)}" maxlength="80" aria-label="Категория ${i + 1}" />
|
||||
<div class="cat-ops">
|
||||
<button type="button" class="ghost cat-up" title="Выше" ${i === 0 ? "disabled" : ""}>↑</button>
|
||||
<button type="button" class="ghost cat-down" title="Ниже" ${i === categories.length - 1 ? "disabled" : ""}>↓</button>
|
||||
<button type="button" class="ghost danger cat-del" title="Удалить">✕</button>
|
||||
</div>
|
||||
</li>`).join("");
|
||||
}
|
||||
|
||||
function readCategoriesFromDom() {
|
||||
const inputs = document.querySelectorAll("#cat-list .cat-name");
|
||||
if (!inputs.length) return categories.slice();
|
||||
return Array.from(inputs).map(el => el.value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function syncFromInputs() {
|
||||
categories = readCategoriesFromDom();
|
||||
}
|
||||
|
||||
document.getElementById("cat-list")?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const item = btn.closest(".cat-item");
|
||||
if (!item) return;
|
||||
syncFromInputs();
|
||||
const i = Number(item.dataset.index);
|
||||
if (btn.classList.contains("cat-del")) {
|
||||
categories.splice(i, 1);
|
||||
} else if (btn.classList.contains("cat-up") && i > 0) {
|
||||
[categories[i - 1], categories[i]] = [categories[i], categories[i - 1]];
|
||||
} else if (btn.classList.contains("cat-down") && i < categories.length - 1) {
|
||||
[categories[i + 1], categories[i]] = [categories[i], categories[i + 1]];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
renderCategories();
|
||||
});
|
||||
|
||||
document.getElementById("cat-add-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("cat-new");
|
||||
const name = (input?.value || "").trim();
|
||||
if (!name) return;
|
||||
syncFromInputs();
|
||||
if (categories.some(c => c.toLowerCase() === name.toLowerCase())) {
|
||||
document.getElementById("cats-status").textContent = "Уже есть в списке";
|
||||
return;
|
||||
}
|
||||
categories.push(name);
|
||||
input.value = "";
|
||||
renderCategories();
|
||||
document.getElementById("cats-status").textContent = "";
|
||||
});
|
||||
|
||||
document.getElementById("cat-new")?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
document.getElementById("cat-add-btn")?.click();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("save-cats")?.addEventListener("click", async () => {
|
||||
const status = document.getElementById("cats-status");
|
||||
syncFromInputs();
|
||||
status.textContent = "Сохранение…";
|
||||
try {
|
||||
const r = await fetch("/api/categories", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ categories }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.detail || r.statusText);
|
||||
categories = data.categories || [];
|
||||
renderCategories();
|
||||
status.textContent = "Сохранено";
|
||||
} catch (e) {
|
||||
status.textContent = "Ошибка: " + e.message;
|
||||
}
|
||||
});
|
||||
|
||||
function eventCardHtml(e) {
|
||||
const bits = [];
|
||||
if (e.category) bits.push(`<strong class="cat">${esc(e.category)}</strong>`);
|
||||
if (e.request_text) bits.push(`<span class="speech">«${esc(e.request_text)}»</span>`);
|
||||
if (e.bid) bits.push(`<span>bid=${esc(e.bid)} (${esc(e.action || "")})</span>`);
|
||||
if (e.path) bits.push(`<span>${esc(e.method || "")} ${esc(e.path)}${e.query ? "?" + esc(e.query) : ""}</span>`);
|
||||
if (e.source_id) bits.push(`<span class="meta">${esc(e.source_id)}</span>`);
|
||||
if (e.error) bits.push(`<span class="meta">${esc(e.error)}</span>`);
|
||||
if (e.reason) bits.push(`<span class="meta">${esc(e.reason)}</span>`);
|
||||
const voice = e.audio_url
|
||||
? `<div class="card-voice">${voiceBubbleHtml(e.audio_url, e.id || e.request_id || "")}</div>`
|
||||
: "";
|
||||
return `<div class="card kind-${esc(e.kind)}">
|
||||
<div class="card-main">
|
||||
<span class="kind">${esc(e.kind)}</span>
|
||||
<span class="ts">${esc(e.ts || "")}</span>
|
||||
${bits.join(" ")}
|
||||
</div>
|
||||
${voice}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch("/api/events?limit=60");
|
||||
const data = await r.json();
|
||||
const active = document.getElementById("active");
|
||||
const feed = document.getElementById("feed");
|
||||
if (!active || !feed) return;
|
||||
|
||||
const activeSig = JSON.stringify(data.active || []);
|
||||
if (activeSig !== lastActiveSig) {
|
||||
lastActiveSig = activeSig;
|
||||
if (!data.active || data.active.length === 0) {
|
||||
active.innerHTML = '<p class="muted">Нет активных вызовов</p>';
|
||||
} else {
|
||||
active.innerHTML = data.active.map(c => `
|
||||
<div class="card active">
|
||||
<div class="row"><span class="label">Категория</span><strong>${esc(c.category || "")}</strong></div>
|
||||
<div class="row"><span class="label">Речь</span><span>${esc(c.request_text || "—")}</span></div>
|
||||
${c.audio_url ? `<div class="card-voice">${voiceBubbleHtml(c.audio_url, c.request_id || "")}</div>` : ""}
|
||||
<span class="meta">${esc(c.source_id || "")} · ${esc((c.request_id || "").slice(0, 8))}…</span>
|
||||
</div>`).join("");
|
||||
bindVoiceBubbles(active);
|
||||
}
|
||||
}
|
||||
|
||||
const feedSig = JSON.stringify((data.events || []).map(e => [e.id, e.kind, e.audio_url]));
|
||||
if (feedSig !== lastFeedSig) {
|
||||
// Don't wipe DOM while user is listening to an event that still exists
|
||||
const playingId = activeAudio?.closest(".voice-bubble")?.dataset.id || "";
|
||||
const stillThere = playingId && (data.events || []).some(e => (e.id || e.request_id) === playingId);
|
||||
if (!(activeAudio && !activeAudio.paused && stillThere)) {
|
||||
lastFeedSig = feedSig;
|
||||
feed.innerHTML = (data.events || []).map(eventCardHtml).join("");
|
||||
bindVoiceBubbles(feed);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
renderCategories();
|
||||
bindVoiceBubbles(document);
|
||||
refresh();
|
||||
setInterval(refresh, 2000);
|
||||
@@ -0,0 +1,259 @@
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--panel: #1a222c;
|
||||
--text: #e8eef4;
|
||||
--muted: #8b9aab;
|
||||
--accent: #3d9cf0;
|
||||
--ok: #3ecf8e;
|
||||
--warn: #f0b429;
|
||||
--err: #f07178;
|
||||
--voice: #c792ea;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
background: radial-gradient(1200px 600px at 10% -10%, #1a2a3a, var(--bg));
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
header h1 { margin: 0 0 0.25rem; font-weight: 650; letter-spacing: -0.02em; }
|
||||
.sub { color: var(--muted); margin: 0 0 1.5rem; }
|
||||
.badge {
|
||||
background: var(--warn);
|
||||
color: #1a1400;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid #2a3542;
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel h2 { margin: 0 0 0.75rem; font-size: 1rem; color: var(--muted); font-weight: 600; }
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #243040;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.card-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem 0.85rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.card.active {
|
||||
background: #152018;
|
||||
border: 1px solid #2a5a3a;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #2a5a3a;
|
||||
}
|
||||
.card-voice { margin-top: 0.15rem; }
|
||||
|
||||
/* Messenger-style voice bubble */
|
||||
.voice-bubble {
|
||||
--progress: 0;
|
||||
display: inline-grid;
|
||||
grid-template-columns: 2.4rem minmax(9rem, 14rem) 2.6rem;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
max-width: 100%;
|
||||
padding: 0.55rem 0.75rem 0.55rem 0.55rem;
|
||||
border-radius: 18px 18px 18px 6px;
|
||||
background: linear-gradient(145deg, #243041 0%, #1a2430 100%);
|
||||
border: 1px solid #314255;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
|
||||
position: relative;
|
||||
}
|
||||
.kind-voice .voice-bubble,
|
||||
.kind-voice_warning .voice-bubble {
|
||||
background: linear-gradient(145deg, #3a2a52 0%, #2a1f3d 100%);
|
||||
border-color: #5a4578;
|
||||
}
|
||||
.voice-bubble audio { display: none; }
|
||||
.voice-play {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #041018;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kind-voice .voice-play,
|
||||
.kind-voice_warning .voice-play {
|
||||
background: var(--voice);
|
||||
color: #1a1024;
|
||||
}
|
||||
.voice-play svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
.voice-play .icon-pause { display: none; }
|
||||
.voice-bubble.playing .icon-play { display: none; }
|
||||
.voice-bubble.playing .icon-pause { display: block; }
|
||||
.voice-wave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 1.6rem;
|
||||
position: relative;
|
||||
}
|
||||
.voice-wave i {
|
||||
display: block;
|
||||
width: 3px;
|
||||
height: var(--h, 40%);
|
||||
border-radius: 2px;
|
||||
background: rgba(232, 238, 244, 0.28);
|
||||
transform-origin: center;
|
||||
}
|
||||
.voice-wave i.filled {
|
||||
background: #7ec8ff;
|
||||
}
|
||||
.kind-voice .voice-wave i.filled,
|
||||
.kind-voice_warning .voice-wave i.filled {
|
||||
background: #e0b7ff;
|
||||
}
|
||||
.voice-bubble.playing .voice-wave i {
|
||||
animation: voicePulse 0.9s ease-in-out infinite;
|
||||
animation-delay: calc(var(--h) * 0.004s);
|
||||
}
|
||||
@keyframes voicePulse {
|
||||
0%, 100% { transform: scaleY(0.72); opacity: 0.7; }
|
||||
50% { transform: scaleY(1.12); opacity: 1; }
|
||||
}
|
||||
.voice-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.voice-bubble.playing .voice-time { color: var(--text); }
|
||||
.kind {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.kind-voice .kind { color: var(--voice); }
|
||||
.kind-voice_warning .kind { color: var(--warn); }
|
||||
.kind-cancel .kind { color: var(--warn); }
|
||||
.kind-press .kind, .kind-latch .kind { color: var(--ok); }
|
||||
.kind-voice_error .kind, .kind-cancel_miss .kind { color: var(--err); }
|
||||
.ts, .meta, .muted, .hint { color: var(--muted); font-size: 0.85rem; }
|
||||
.hint { margin: 0 0 0.75rem; }
|
||||
.row { display: flex; gap: 0.75rem; align-items: baseline; }
|
||||
.row .label { color: var(--muted); min-width: 5.5rem; font-size: 0.85rem; }
|
||||
.speech { font-style: italic; }
|
||||
.cat { color: var(--voice); }
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.cat-item {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
background: #121820;
|
||||
border: 1px solid #2a3542;
|
||||
border-radius: 10px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
.cat-order {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cat-name {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.25rem;
|
||||
outline: none;
|
||||
}
|
||||
.cat-name:focus {
|
||||
background: #0c1016;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.cat-ops { display: flex; gap: 0.25rem; }
|
||||
.cat-empty {
|
||||
padding: 0.85rem 0.5rem;
|
||||
border: 1px dashed #2a3542;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.cat-add {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.cat-add input {
|
||||
background: #0f1419;
|
||||
color: var(--text);
|
||||
border: 1px solid #2a3542;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid #2a3542;
|
||||
border-radius: 6px;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
button.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: #3d4f63;
|
||||
}
|
||||
button.ghost:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
button.ghost.danger:hover:not(:disabled) {
|
||||
color: var(--err);
|
||||
border-color: #6a3038;
|
||||
}
|
||||
.actions { margin-top: 0.75rem; display: flex; gap: 1rem; align-items: center; }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #041018;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.card-main { flex-direction: column; gap: 0.25rem; }
|
||||
.cat-item { grid-template-columns: 1.5rem 1fr; }
|
||||
.cat-ops { grid-column: 1 / -1; justify-content: flex-end; }
|
||||
.voice-bubble { grid-template-columns: 2.2rem minmax(7rem, 1fr) 2.4rem; width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ButtonTask Demo</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ButtonTask Demo</h1>
|
||||
<p class="sub">Голос · обычные нажатия · отмены{% if mock_ai %} · <span class="badge">MOCK AI</span>{% endif %}</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Активные голосовые вызовы</h2>
|
||||
<div id="active">
|
||||
{% if active %}
|
||||
{% for c in active %}
|
||||
<div class="card active">
|
||||
<div class="row"><span class="label">Категория</span><strong>{{ c.category }}</strong></div>
|
||||
<div class="row"><span class="label">Речь</span><span>{{ c.request_text }}</span></div>
|
||||
{% if c.audio_url %}
|
||||
<div class="card-voice">
|
||||
<div class="voice-bubble" data-src="{{ c.audio_url }}" data-id="{{ c.request_id }}">
|
||||
<button type="button" class="voice-play" aria-label="Воспроизвести">
|
||||
<svg class="icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>
|
||||
</button>
|
||||
<div class="voice-wave" aria-hidden="true"></div>
|
||||
<span class="voice-time">0:00</span>
|
||||
<audio preload="metadata" src="{{ c.audio_url }}"></audio>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="meta">{{ c.source_id }} · {{ c.request_id[:8] }}…</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="muted">Нет активных вызовов</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Категории</h2>
|
||||
<p class="hint">Тишина или фраза вне списка — кнопка покажет предупреждение без ожидания сброса.</p>
|
||||
<ul id="cat-list" class="cat-list" aria-label="Список категорий"></ul>
|
||||
<div class="cat-add">
|
||||
<input type="text" id="cat-new" placeholder="Новая категория" maxlength="80" autocomplete="off" />
|
||||
<button type="button" id="cat-add-btn" class="primary">Добавить</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="save-cats" class="primary">Сохранить</button>
|
||||
<span id="cats-status" class="muted"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Лента событий</h2>
|
||||
<div id="feed">
|
||||
{% for e in events %}
|
||||
<div class="card kind-{{ e.kind }}">
|
||||
<div class="card-main">
|
||||
<span class="kind">{{ e.kind }}</span>
|
||||
<span class="ts">{{ e.ts }}</span>
|
||||
{% if e.category %}<strong class="cat">{{ e.category }}</strong>{% endif %}
|
||||
{% if e.request_text %}<span class="speech">«{{ e.request_text }}»</span>{% endif %}
|
||||
{% if e.bid %}<span>bid={{ e.bid }} ({{ e.action }})</span>{% endif %}
|
||||
{% if e.path %}<span>{{ e.method }} {{ e.path }}{% if e.query %}?{{ e.query }}{% endif %}</span>{% endif %}
|
||||
{% if e.source_id %}<span class="meta">{{ e.source_id }}</span>{% endif %}
|
||||
</div>
|
||||
{% if e.audio_url %}
|
||||
<div class="card-voice">
|
||||
<div class="voice-bubble" data-src="{{ e.audio_url }}" data-id="{{ e.id }}">
|
||||
<button type="button" class="voice-play" aria-label="Воспроизвести">
|
||||
<svg class="icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>
|
||||
</button>
|
||||
<div class="voice-wave" aria-hidden="true"></div>
|
||||
<span class="voice-time">0:00</span>
|
||||
<audio preload="metadata" src="{{ e.audio_url }}"></audio>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
window.__INITIAL_CATEGORIES__ = {{ categories | tojson }};
|
||||
</script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+38
-25
@@ -11,8 +11,18 @@ Item {
|
||||
readonly property string triggerModeResolved: (typeof triggerMode !== "undefined" && triggerMode) ? triggerMode : "hold"
|
||||
readonly property int holdMsResolved: (typeof holdMs !== "undefined" && holdMs > 0) ? holdMs : 800
|
||||
readonly property bool latchOn: typeof latchResetEnabled !== "undefined" && latchResetEnabled
|
||||
readonly property bool inputBlocked: status === 3 || status === 2
|
||||
|| (status !== 0 && !latchOn)
|
||||
readonly property bool isVoice: (typeof kind !== "undefined" && kind === "voice")
|
||||
// Voice: allow interaction while lit (cancel) and while recording (stop in click mode).
|
||||
// Block only while uploading (status 3 and not recording) or error.
|
||||
readonly property bool voiceRecording: isVoice && status === 3 && btnController.isVoiceRecording(btnId)
|
||||
readonly property bool inputBlocked: {
|
||||
if (isVoice) {
|
||||
if (status === 2) return true
|
||||
if (status === 3 && !voiceRecording) return true // uploading
|
||||
return false
|
||||
}
|
||||
return status === 3 || status === 2 || (status !== 0 && !latchOn)
|
||||
}
|
||||
|
||||
// Resolved feedback colour/width for the current status (ok / error).
|
||||
readonly property color okColor: settingsContainer.feedbackOkColor
|
||||
@@ -72,10 +82,6 @@ Item {
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: btnColor || "#7b007b"
|
||||
// Idle: no ring. Feedback states draw the configured coloured outline.
|
||||
// ColorBehavior is only enabled while in a feedback state so that on
|
||||
// return to idle the colour snaps away immediately — otherwise a ~1px
|
||||
// red (or green) remnant stays visible while width animates down.
|
||||
border.color: status === 1 ? delegateRoot.okColor
|
||||
: status === 2 ? delegateRoot.errorColor
|
||||
: status === 3 ? delegateRoot.pendingColor
|
||||
@@ -87,12 +93,10 @@ Item {
|
||||
ColorAnimation { duration: 200 }
|
||||
}
|
||||
|
||||
// Hold-to-activate state (0..1). Only used in "hold" trigger mode.
|
||||
property real holdProgress: 0.0
|
||||
property bool holding: false
|
||||
|
||||
// Squash slightly while held for tactile feedback.
|
||||
scale: holding ? 0.93 : 1.0
|
||||
scale: holding || delegateRoot.voiceRecording ? 0.93 : 1.0
|
||||
Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
|
||||
|
||||
Image {
|
||||
@@ -105,15 +109,14 @@ Item {
|
||||
asynchronous: true
|
||||
}
|
||||
|
||||
// Circular progress arc that fills up while the button is held.
|
||||
// Circular progress arc that fills up while the button is held (press buttons only).
|
||||
Canvas {
|
||||
id: holdRing
|
||||
anchors.fill: parent
|
||||
anchors.margins: -Math.max(3, width * 0.04)
|
||||
visible: circle.holdProgress > 0.001
|
||||
visible: !delegateRoot.isVoice && circle.holdProgress > 0.001
|
||||
antialiasing: true
|
||||
renderStrategy: Canvas.Threaded
|
||||
// Ring colour from global hold feedback settings.
|
||||
property color baseColor: Qt.lighter(delegateRoot.holdColor, 1.2)
|
||||
property color headColor: delegateRoot.holdColor
|
||||
function mix(c1, c2, t) {
|
||||
@@ -135,7 +138,6 @@ Item {
|
||||
var sweep = p * 2 * Math.PI
|
||||
var end = start + sweep
|
||||
|
||||
// Recessed track underneath for a sense of depth.
|
||||
ctx.beginPath()
|
||||
ctx.lineWidth = lw
|
||||
ctx.lineCap = "round"
|
||||
@@ -143,43 +145,35 @@ Item {
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI, false)
|
||||
ctx.stroke()
|
||||
|
||||
// Progress arc drawn as overlapping segments with a manual
|
||||
// colour fade (dim tail -> bright head). Segment boundaries are
|
||||
// fixed in ANGLE space (not derived from progress) so they never
|
||||
// shift between frames -> no jitter. Opaque colours + overlap
|
||||
// hide the joints. Avoids the seam a conical gradient produces.
|
||||
var tail = Qt.darker(baseColor, 1.5) // opaque dim tail
|
||||
var tail = Qt.darker(baseColor, 1.5)
|
||||
var N = 120
|
||||
var segAng = 2 * Math.PI / N
|
||||
var eps = Math.min(segAng * 0.5, 1.5 / r) // ~1px opaque overlap
|
||||
var eps = Math.min(segAng * 0.5, 1.5 / r)
|
||||
ctx.lineCap = "butt"
|
||||
ctx.lineWidth = lw
|
||||
for (var i = 0; i < N; i++) {
|
||||
var a0 = i * segAng
|
||||
if (a0 >= sweep) break
|
||||
var a1 = Math.min((i + 1) * segAng, sweep)
|
||||
var t = sweep > 0 ? (a0 / sweep) : 0 // 0..1 along progress
|
||||
var t = sweep > 0 ? (a0 / sweep) : 0
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = mix(tail, headColor, t)
|
||||
ctx.arc(cx, cy, r, start + a0 - eps, start + a1 + eps, false)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Rounded tail cap.
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = tail
|
||||
ctx.arc(cx + r * Math.cos(start), cy + r * Math.sin(start),
|
||||
lw / 2, 0, 2 * Math.PI, false)
|
||||
ctx.fill()
|
||||
|
||||
// Bright rounded head for a glossy, raised feel.
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = headColor
|
||||
ctx.arc(cx + r * Math.cos(end), cy + r * Math.sin(end),
|
||||
lw * 0.55, 0, 2 * Math.PI, false)
|
||||
ctx.fill()
|
||||
}
|
||||
// Soft bloom around the ring for volume.
|
||||
layer.enabled: true
|
||||
layer.effect: Glow {
|
||||
radius: 14
|
||||
@@ -229,6 +223,11 @@ Item {
|
||||
enabled: !delegateRoot.inputBlocked
|
||||
onPressed: {
|
||||
if (delegateRoot.inputBlocked) return
|
||||
if (delegateRoot.isVoice) {
|
||||
circle.holding = true
|
||||
btnController.voicePointerPressed(btnId)
|
||||
return
|
||||
}
|
||||
if (delegateRoot.triggerModeResolved !== "click") {
|
||||
resetAnim.stop()
|
||||
circle.holding = true
|
||||
@@ -236,14 +235,23 @@ Item {
|
||||
}
|
||||
}
|
||||
onReleased: {
|
||||
if (delegateRoot.isVoice) {
|
||||
circle.holding = false
|
||||
btnController.voicePointerReleased(btnId)
|
||||
return
|
||||
}
|
||||
if (delegateRoot.triggerModeResolved !== "click" && circle.holding) {
|
||||
// Released too early — cancel and rewind.
|
||||
circle.holding = false
|
||||
holdAnim.stop()
|
||||
resetAnim.restart()
|
||||
}
|
||||
}
|
||||
onCanceled: {
|
||||
if (delegateRoot.isVoice) {
|
||||
circle.holding = false
|
||||
btnController.voicePointerReleased(btnId)
|
||||
return
|
||||
}
|
||||
if (delegateRoot.triggerModeResolved !== "click") {
|
||||
circle.holding = false
|
||||
holdAnim.stop()
|
||||
@@ -252,6 +260,11 @@ Item {
|
||||
}
|
||||
onClicked: {
|
||||
if (delegateRoot.inputBlocked) return
|
||||
if (delegateRoot.isVoice) {
|
||||
if (status === 1 || delegateRoot.triggerModeResolved === "click")
|
||||
btnController.voiceClicked(btnId)
|
||||
return
|
||||
}
|
||||
if (delegateRoot.triggerModeResolved === "click")
|
||||
btnController.invokeButton(btnId)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ Dialog {
|
||||
title: editId === "" ? "Новая кнопка" : "Редактирование"
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(520, parent ? parent.width * 0.95 : 520)
|
||||
height: Math.min(680, parent ? parent.height * 0.9 : 680)
|
||||
height: Math.min(720, parent ? parent.height * 0.92 : 720)
|
||||
standardButtons: Dialog.Ok | Dialog.Cancel
|
||||
|
||||
readonly property color panelBg: "#161628"
|
||||
readonly property color panelFg: "#ffffff"
|
||||
readonly property color controlBg: "#2a2a40"
|
||||
readonly property bool isVoice: kindCombo.currentIndex === 1
|
||||
|
||||
palette.button: controlBg
|
||||
palette.buttonText: panelFg
|
||||
@@ -46,6 +47,7 @@ Dialog {
|
||||
iconCombo.model = btnController.listIcons()
|
||||
iconCombo.currentIndex = 0
|
||||
typeCombo.currentIndex = 0
|
||||
kindCombo.currentIndex = 0
|
||||
holdSwitch.checked = true
|
||||
holdSlider.value = 800
|
||||
latchSwitch.checked = true
|
||||
@@ -54,6 +56,9 @@ Dialog {
|
||||
fireAndForgetSwitch.checked = false
|
||||
responseCheckSwitch.checked = false
|
||||
responseCheckWasEnabled = false
|
||||
cancelUrlF.text = ""
|
||||
sourceIdF.text = "panel-01"
|
||||
apiKeyF.text = ""
|
||||
open()
|
||||
}
|
||||
|
||||
@@ -74,6 +79,7 @@ Dialog {
|
||||
var idx = iconCombo.model.indexOf(fname)
|
||||
iconCombo.currentIndex = idx >= 0 ? idx : 0
|
||||
typeCombo.currentIndex = (o.actionType === "http_post") ? 1 : 0
|
||||
kindCombo.currentIndex = (o.kind === "voice") ? 1 : 0
|
||||
holdSwitch.checked = (o.triggerMode !== "click")
|
||||
holdSlider.value = (o.holdMs && o.holdMs > 0) ? o.holdMs : 800
|
||||
latchSwitch.checked = !!o.latchResetEnabled
|
||||
@@ -82,6 +88,9 @@ Dialog {
|
||||
fireAndForgetSwitch.checked = !!o.latchFireAndForget
|
||||
responseCheckSwitch.checked = !!o.responseCheckEnabled
|
||||
responseCheckWasEnabled = responseCheckSwitch.checked
|
||||
cancelUrlF.text = o.voiceCancelUrl || ""
|
||||
sourceIdF.text = o.voiceSourceId || "panel-01"
|
||||
apiKeyF.text = o.apiKey || ""
|
||||
open()
|
||||
}
|
||||
|
||||
@@ -98,6 +107,15 @@ Dialog {
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Подпись"
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Text { text: "Тип:"; color: editorDialog.panelFg }
|
||||
ComboBox {
|
||||
id: kindCombo
|
||||
Layout.fillWidth: true
|
||||
model: ["Нажатие", "Голос (запись)"]
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Text { text: "Иконка:"; color: editorDialog.panelFg }
|
||||
@@ -113,6 +131,7 @@ Dialog {
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: !editorDialog.isVoice
|
||||
Text { text: "Метод:"; color: editorDialog.panelFg }
|
||||
ComboBox {
|
||||
id: typeCombo
|
||||
@@ -122,58 +141,92 @@ Dialog {
|
||||
TextField {
|
||||
id: urlF
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "URL"
|
||||
placeholderText: editorDialog.isVoice ? "URL classify-audio" : "URL"
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Text { text: "Сброс повторным нажатием:"; color: editorDialog.panelFg }
|
||||
Switch { id: latchSwitch; checked: true }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked
|
||||
text: "Первое нажатие — URL выше. Успех, если в ответе есть указанный текст или HTTP < 400. Зелёный статус держится до повторного нажатия."
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
TextField {
|
||||
id: resetUrlF
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked
|
||||
placeholderText: "URL сброса"
|
||||
}
|
||||
TextField {
|
||||
id: successMatchF
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked
|
||||
placeholderText: "Успех если в ответе (OK)"
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked
|
||||
Text {
|
||||
text: "Мгновенный отклик:"
|
||||
color: editorDialog.panelFg
|
||||
spacing: 6
|
||||
visible: editorDialog.isVoice
|
||||
TextField {
|
||||
id: cancelUrlF
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "URL отмены (cancel)"
|
||||
}
|
||||
TextField {
|
||||
id: sourceIdF
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "source_id устройства"
|
||||
}
|
||||
TextField {
|
||||
id: apiKeyF
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "X-API-Key"
|
||||
echoMode: TextInput.Password
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Удержание: запись пока зажата. Клик: старт/стоп записи. Повторное нажатие на горящей кнопке — отмена."
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Switch { id: fireAndForgetSwitch }
|
||||
}
|
||||
Text {
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked && fireAndForgetSwitch.checked
|
||||
text: "Статус обновляется сразу после нажатия; команда на сервер уходит параллельно."
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.WordWrap
|
||||
spacing: 6
|
||||
visible: !editorDialog.isVoice
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Text { text: "Сброс повторным нажатием:"; color: editorDialog.panelFg }
|
||||
Switch { id: latchSwitch; checked: true }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked
|
||||
text: "Первое нажатие — URL выше. Успех, если в ответе есть указанный текст или HTTP < 400. Зелёный статус держится до повторного нажатия."
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
TextField {
|
||||
id: resetUrlF
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked
|
||||
placeholderText: "URL сброса"
|
||||
}
|
||||
TextField {
|
||||
id: successMatchF
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked
|
||||
placeholderText: "Успех если в ответе (OK)"
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked
|
||||
Text {
|
||||
text: "Мгновенный отклик:"
|
||||
color: editorDialog.panelFg
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Switch { id: fireAndForgetSwitch }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: latchSwitch.checked && fireAndForgetSwitch.checked
|
||||
text: "Статус обновляется сразу после нажатия; команда на сервер уходит параллельно."
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: successF
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Текст успеха"
|
||||
placeholderText: editorDialog.isVoice ? "Текст успеха (пусто = категория)" : "Текст успеха"
|
||||
}
|
||||
TextField {
|
||||
id: errorF
|
||||
@@ -183,7 +236,7 @@ Dialog {
|
||||
TextField {
|
||||
id: pendingF
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Текст ожидания"
|
||||
placeholderText: editorDialog.isVoice ? "Текст записи (Слушаю...)" : "Текст ожидания"
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
@@ -192,7 +245,6 @@ Dialog {
|
||||
label: "Цвет:"
|
||||
onColorChanged: colorF.text = hex
|
||||
}
|
||||
// Hidden field keeps save handler unchanged.
|
||||
TextField {
|
||||
id: colorF
|
||||
visible: false
|
||||
@@ -201,13 +253,18 @@ Dialog {
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Text { text: "Срабатывание:"; color: editorDialog.panelFg }
|
||||
Text {
|
||||
text: editorDialog.isVoice ? "Жест записи:" : "Срабатывание:"
|
||||
color: editorDialog.panelFg
|
||||
}
|
||||
Switch {
|
||||
id: holdSwitch
|
||||
checked: true
|
||||
}
|
||||
Text {
|
||||
text: holdSwitch.checked ? "Удержание" : "Клик"
|
||||
text: holdSwitch.checked
|
||||
? (editorDialog.isVoice ? "Удержание + речь" : "Удержание")
|
||||
: (editorDialog.isVoice ? "Клик старт/стоп" : "Клик")
|
||||
color: "#cccccc"
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
@@ -215,7 +272,7 @@ Dialog {
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: holdSwitch.checked
|
||||
visible: holdSwitch.checked && !editorDialog.isVoice
|
||||
Text {
|
||||
text: "Длительность: " + Math.round(holdSlider.value) + " мс"
|
||||
color: editorDialog.panelFg
|
||||
@@ -231,7 +288,7 @@ Dialog {
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: !latchSwitch.checked
|
||||
visible: !editorDialog.isVoice && !latchSwitch.checked
|
||||
Text { text: "Расширенная проверка:"; color: editorDialog.panelFg }
|
||||
Switch { id: responseCheckSwitch }
|
||||
Text {
|
||||
@@ -248,23 +305,31 @@ Dialog {
|
||||
onAccepted: {
|
||||
var mode = holdSwitch.checked ? "hold" : "click"
|
||||
var hold = Math.round(holdSlider.value)
|
||||
var latchOn = latchSwitch.checked
|
||||
var kind = editorDialog.isVoice ? "voice" : "press"
|
||||
var latchOn = !editorDialog.isVoice && latchSwitch.checked
|
||||
var resetUrl = resetUrlF.text.trim()
|
||||
var successMatch = successMatchF.text.trim() || "OK"
|
||||
var fireAndForget = latchOn && fireAndForgetSwitch.checked
|
||||
var cancelUrl = cancelUrlF.text.trim()
|
||||
var sourceId = sourceIdF.text.trim() || "panel-01"
|
||||
var apiKey = apiKeyF.text.trim()
|
||||
var actionType = editorDialog.isVoice ? "http_post" : typeCombo.currentText
|
||||
if (editId === "") {
|
||||
btnController.addButton(labelF.text, iconCombo.currentText,
|
||||
typeCombo.currentText, urlF.text,
|
||||
actionType, urlF.text,
|
||||
successF.text, errorF.text, pendingF.text,
|
||||
colorF.text, mode, hold,
|
||||
latchOn, resetUrl, successMatch, fireAndForget)
|
||||
latchOn, resetUrl, successMatch, fireAndForget,
|
||||
kind, cancelUrl, sourceId, 30000, apiKey)
|
||||
} else {
|
||||
btnController.updateButton(editId, labelF.text, iconCombo.currentText,
|
||||
typeCombo.currentText, urlF.text,
|
||||
actionType, urlF.text,
|
||||
successF.text, errorF.text, pendingF.text,
|
||||
colorF.text, mode, hold,
|
||||
latchOn, resetUrl, successMatch, fireAndForget)
|
||||
if (!latchOn && responseCheckSwitch.checked !== responseCheckWasEnabled)
|
||||
latchOn, resetUrl, successMatch, fireAndForget,
|
||||
kind, cancelUrl, sourceId, 30000, apiKey)
|
||||
if (!latchOn && !editorDialog.isVoice
|
||||
&& responseCheckSwitch.checked !== responseCheckWasEnabled)
|
||||
btnController.setResponseCheckEnabled(editId, responseCheckSwitch.checked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ Item {
|
||||
property string resultText: ""
|
||||
property bool resultOk: false
|
||||
|
||||
function deviceType(name) {
|
||||
for (var i = 0; i < devices.length; ++i) {
|
||||
if (devices[i].device === name)
|
||||
return devices[i].type || "ethernet"
|
||||
}
|
||||
return "ethernet"
|
||||
}
|
||||
|
||||
function syncWifiVisibility() {
|
||||
var t = deviceType(ifaceCombo.currentText)
|
||||
wifiFields.visible = (t === "wifi")
|
||||
}
|
||||
|
||||
function reload() {
|
||||
devices = systemInfo.networkInfo()
|
||||
savedConfig = systemInfo.networkConfig()
|
||||
@@ -32,6 +45,9 @@ Item {
|
||||
} else {
|
||||
staticSwitch.checked = false
|
||||
}
|
||||
ssidField.text = savedConfig.ssid || ""
|
||||
passField.text = savedConfig.password || ""
|
||||
syncWifiVisibility()
|
||||
}
|
||||
|
||||
function applyNow() {
|
||||
@@ -41,15 +57,28 @@ Item {
|
||||
return
|
||||
}
|
||||
var mode = staticSwitch.checked ? "static" : "dhcp"
|
||||
var netType = deviceType(ifaceCombo.currentText)
|
||||
if (netType === "wifi" && !ssidField.text.trim()) {
|
||||
page.resultOk = false
|
||||
page.resultText = "Укажите SSID"
|
||||
return
|
||||
}
|
||||
var r
|
||||
if (mode === "static") {
|
||||
r = systemInfo.applyNetwork(ifaceCombo.currentText, "static",
|
||||
addrField.text.trim(),
|
||||
parseInt(prefixField.text) || 24,
|
||||
gwField.text.trim(),
|
||||
dnsField.text.trim())
|
||||
dnsField.text.trim(),
|
||||
netType,
|
||||
ssidField.text.trim(),
|
||||
passField.text)
|
||||
} else {
|
||||
r = systemInfo.applyNetwork(ifaceCombo.currentText, "dhcp")
|
||||
r = systemInfo.applyNetwork(ifaceCombo.currentText, "dhcp",
|
||||
"", 24, "", "",
|
||||
netType,
|
||||
ssidField.text.trim(),
|
||||
passField.text)
|
||||
}
|
||||
page.resultOk = r.ok === true
|
||||
page.resultText = r.ok ? "Настройки применены" : ("Ошибка: " + (r.error || "неизвестно"))
|
||||
@@ -88,7 +117,7 @@ Item {
|
||||
Button { text: "\u2190 Назад"; onClicked: page.StackView.view.pop() }
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: "Сеть (Ethernet)"
|
||||
text: "Сеть (Ethernet / Wi‑Fi)"
|
||||
font.bold: true
|
||||
font.pixelSize: 16
|
||||
color: "#ffffff"
|
||||
@@ -97,11 +126,10 @@ Item {
|
||||
Button { text: "\u21BB"; onClicked: page.reload() }
|
||||
}
|
||||
|
||||
// ----- Current state -----
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: page.devices.length === 0
|
||||
text: "Ethernet-интерфейсы не найдены (или nmcli недоступен)."
|
||||
text: "Сетевые интерфейсы не найдены (или nmcli недоступен)."
|
||||
color: "#cccccc"
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
@@ -122,7 +150,7 @@ Item {
|
||||
anchors.margins: 8
|
||||
spacing: 2
|
||||
Text {
|
||||
text: modelData.device + " (" + modelData.state + ")"
|
||||
text: modelData.device + " [" + (modelData.type || "?") + "] (" + modelData.state + ")"
|
||||
color: "#ffffff"
|
||||
font.bold: true
|
||||
}
|
||||
@@ -141,13 +169,25 @@ Item {
|
||||
color: "#cccccc"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Button {
|
||||
text: "Отключить"
|
||||
visible: (modelData.state || "").indexOf("connected") >= 0
|
||||
|| (modelData.state || "").indexOf("connecting") >= 0
|
||||
onClicked: {
|
||||
var r = systemInfo.disconnectNetwork(modelData.device)
|
||||
page.resultOk = r.ok === true
|
||||
page.resultText = r.ok
|
||||
? ("Отключено: " + modelData.device)
|
||||
: ("Ошибка: " + (r.error || "неизвестно"))
|
||||
reloadTimer.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: "#40ffffff" }
|
||||
|
||||
// ----- Configuration form -----
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Изменение настроек может разорвать соединение и сменить IP устройства."
|
||||
@@ -163,6 +203,30 @@ Item {
|
||||
id: ifaceCombo
|
||||
Layout.fillWidth: true
|
||||
model: []
|
||||
onCurrentTextChanged: page.syncWifiVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: wifiFields
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
visible: false
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Text { text: "SSID:"; color: "#ffffff"; Layout.preferredWidth: 120 }
|
||||
TextField { id: ssidField; Layout.fillWidth: true; placeholderText: "Имя сети Wi‑Fi" }
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Text { text: "Пароль:"; color: "#ffffff"; Layout.preferredWidth: 120 }
|
||||
TextField {
|
||||
id: passField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Пароль (пустой = открытая сеть)"
|
||||
echoMode: TextInput.Password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#include "audiorecorder.h"
|
||||
|
||||
#include <QAudio>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QDataStream>
|
||||
#include <QDebug>
|
||||
#include <QUuid>
|
||||
#include <QList>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
|
||||
bool pulseSocketExists()
|
||||
{
|
||||
const QString runtime = qEnvironmentVariable("XDG_RUNTIME_DIR");
|
||||
if (!runtime.isEmpty() && QFile::exists(runtime + QLatin1String("/pulse/native")))
|
||||
return true;
|
||||
if (QFile::exists(QStringLiteral("/run/pulse/native")))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
QAudioFormat makeFormat(int rate, int channels)
|
||||
{
|
||||
QAudioFormat fmt;
|
||||
fmt.setSampleRate(rate);
|
||||
fmt.setChannelCount(channels);
|
||||
fmt.setSampleSize(16);
|
||||
fmt.setCodec(QStringLiteral("audio/pcm"));
|
||||
fmt.setByteOrder(QAudioFormat::LittleEndian);
|
||||
fmt.setSampleType(QAudioFormat::SignedInt);
|
||||
return fmt;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AudioRecorder::AudioRecorder(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_maxTimer.setSingleShot(true);
|
||||
connect(&m_maxTimer, &QTimer::timeout, this, [this]() {
|
||||
// Controller decides when to stop (may append a silence post-roll first).
|
||||
if (m_recording)
|
||||
emit maxDurationReached();
|
||||
});
|
||||
}
|
||||
|
||||
AudioRecorder::~AudioRecorder()
|
||||
{
|
||||
cancel();
|
||||
}
|
||||
|
||||
int AudioRecorder::scoreInputDevice(const QAudioDeviceInfo &info)
|
||||
{
|
||||
const QString n = info.deviceName().toLower();
|
||||
|
||||
if (n.contains(QLatin1String("usbstream"))
|
||||
|| n.contains(QLatin1String("usb stream")))
|
||||
return -10000;
|
||||
if (n.contains(QLatin1String("jack")))
|
||||
return -10000;
|
||||
if (n.contains(QLatin1String("pulse")))
|
||||
return pulseSocketExists() ? 10 : -10000;
|
||||
|
||||
int s = 0;
|
||||
if (n.contains(QLatin1String("hdmi"))
|
||||
|| n.contains(QLatin1String("monitor"))
|
||||
|| n.contains(QLatin1String("loopback"))
|
||||
|| n.contains(QLatin1String("null")))
|
||||
s -= 500;
|
||||
|
||||
if (n == QLatin1String("default") || n.startsWith(QLatin1String("default:")))
|
||||
s += 120;
|
||||
if (n.startsWith(QLatin1String("sysdefault")) || n.contains(QLatin1String("sysdefault:")))
|
||||
s += 110;
|
||||
if (n.startsWith(QLatin1String("plughw")) || n.contains(QLatin1String("plughw:")))
|
||||
s += 100;
|
||||
if (n.startsWith(QLatin1String("hw:")) || n.startsWith(QLatin1String("hw,")))
|
||||
s += 80;
|
||||
|
||||
if (n.contains(QLatin1String("rk809"))
|
||||
|| n.contains(QLatin1String("es8323"))
|
||||
|| n.contains(QLatin1String("rt56"))
|
||||
|| n.contains(QLatin1String("analog")))
|
||||
s += 40;
|
||||
if (n.contains(QLatin1String("mic"))
|
||||
|| n.contains(QLatin1String("headset"))
|
||||
|| n.contains(QLatin1String("capture")))
|
||||
s += 30;
|
||||
if (n.contains(QLatin1String("rockchip")) && !n.contains(QLatin1String("hdmi")))
|
||||
s += 15;
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
QList<QAudioDeviceInfo> AudioRecorder::rankedInputDevices() const
|
||||
{
|
||||
QList<QAudioDeviceInfo> devices = QAudioDeviceInfo::availableDevices(QAudio::AudioInput);
|
||||
const QAudioDeviceInfo def = QAudioDeviceInfo::defaultInputDevice();
|
||||
if (!def.isNull()) {
|
||||
bool has = false;
|
||||
for (const QAudioDeviceInfo &d : devices) {
|
||||
if (d.deviceName() == def.deviceName()) {
|
||||
has = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!has)
|
||||
devices.prepend(def);
|
||||
}
|
||||
|
||||
std::stable_sort(devices.begin(), devices.end(),
|
||||
[](const QAudioDeviceInfo &a, const QAudioDeviceInfo &b) {
|
||||
return scoreInputDevice(a) > scoreInputDevice(b);
|
||||
});
|
||||
|
||||
qInfo() << "AudioRecorder: candidate inputs:";
|
||||
for (const QAudioDeviceInfo &d : devices) {
|
||||
const int sc = scoreInputDevice(d);
|
||||
if (sc <= -1000)
|
||||
continue;
|
||||
qInfo() << " " << sc << d.deviceName();
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
QAudioDeviceInfo AudioRecorder::pickInputDevice() const
|
||||
{
|
||||
const QList<QAudioDeviceInfo> ranked = rankedInputDevices();
|
||||
for (const QAudioDeviceInfo &d : ranked) {
|
||||
if (scoreInputDevice(d) > -1000)
|
||||
return d;
|
||||
}
|
||||
return QAudioDeviceInfo::defaultInputDevice();
|
||||
}
|
||||
|
||||
bool AudioRecorder::openOutput()
|
||||
{
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
|
||||
QDir().mkpath(dir);
|
||||
m_path = dir + QLatin1String("/bt-voice-") + QUuid::createUuid().toString(QUuid::WithoutBraces)
|
||||
+ QLatin1String(".wav");
|
||||
m_file.setFileName(m_path);
|
||||
if (!m_file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
m_lastError = QStringLiteral("Cannot open temp wav");
|
||||
m_path.clear();
|
||||
return false;
|
||||
}
|
||||
QByteArray header(44, '\0');
|
||||
m_file.write(header);
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioRecorder::writeWavHeader(qint64 dataSize)
|
||||
{
|
||||
if (!m_file.isOpen())
|
||||
return;
|
||||
const quint32 byteRate = static_cast<quint32>(m_sampleRate * m_channels * m_sampleSize / 8);
|
||||
const quint16 blockAlign = static_cast<quint16>(m_channels * m_sampleSize / 8);
|
||||
const quint32 riffSize = static_cast<quint32>(36 + dataSize);
|
||||
|
||||
m_file.seek(0);
|
||||
QDataStream out(&m_file);
|
||||
out.setByteOrder(QDataStream::LittleEndian);
|
||||
out.writeRawData("RIFF", 4);
|
||||
out << riffSize;
|
||||
out.writeRawData("WAVE", 4);
|
||||
out.writeRawData("fmt ", 4);
|
||||
out << quint32(16);
|
||||
out << quint16(1);
|
||||
out << quint16(m_channels);
|
||||
out << quint32(m_sampleRate);
|
||||
out << byteRate;
|
||||
out << blockAlign;
|
||||
out << quint16(m_sampleSize);
|
||||
out.writeRawData("data", 4);
|
||||
out << quint32(dataSize);
|
||||
m_file.flush();
|
||||
}
|
||||
|
||||
void AudioRecorder::cleanupInput()
|
||||
{
|
||||
m_maxTimer.stop();
|
||||
if (m_input) {
|
||||
m_input->stop();
|
||||
m_input->deleteLater();
|
||||
m_input = nullptr;
|
||||
}
|
||||
m_device = nullptr;
|
||||
if (m_file.isOpen())
|
||||
m_file.close();
|
||||
}
|
||||
|
||||
bool AudioRecorder::tryStartFormat(const QAudioDeviceInfo &info, const QAudioFormat &fmt)
|
||||
{
|
||||
auto *input = new QAudioInput(info, fmt, this);
|
||||
input->setVolume(1.0);
|
||||
input->setBufferSize(fmt.bytesForDuration(100000));
|
||||
|
||||
QIODevice *dev = input->start();
|
||||
if (!dev || input->error() != QAudio::NoError
|
||||
|| input->state() == QAudio::StoppedState) {
|
||||
input->stop();
|
||||
input->deleteLater();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_input = input;
|
||||
m_device = dev;
|
||||
m_sampleRate = fmt.sampleRate();
|
||||
m_channels = fmt.channelCount();
|
||||
m_sampleSize = fmt.sampleSize();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioRecorder::openInput(const QAudioDeviceInfo &info)
|
||||
{
|
||||
const QList<int> rates = {48000, 44100, 32000, 16000, 22050};
|
||||
const QList<int> channels = {1, 2};
|
||||
|
||||
for (int ch : channels) {
|
||||
for (int rate : rates) {
|
||||
const QAudioFormat fmt = makeFormat(rate, ch);
|
||||
if (tryStartFormat(info, fmt)) {
|
||||
qInfo() << "AudioRecorder: opened" << info.deviceName()
|
||||
<< "format" << m_sampleRate << "Hz"
|
||||
<< m_channels << "ch" << m_sampleSize << "bit";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qWarning() << "AudioRecorder: failed to start on" << info.deviceName();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AudioRecorder::start(int maxMs)
|
||||
{
|
||||
if (m_recording)
|
||||
return false;
|
||||
|
||||
m_lastError.clear();
|
||||
m_sampleRate = 16000;
|
||||
m_channels = 1;
|
||||
m_sampleSize = 16;
|
||||
|
||||
const QList<QAudioDeviceInfo> ranked = rankedInputDevices();
|
||||
if (ranked.isEmpty()) {
|
||||
m_lastError = QStringLiteral("No audio input device");
|
||||
qWarning() << "AudioRecorder:" << m_lastError;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!openOutput())
|
||||
return false;
|
||||
|
||||
bool opened = false;
|
||||
for (const QAudioDeviceInfo &info : ranked) {
|
||||
if (scoreInputDevice(info) <= -1000)
|
||||
continue;
|
||||
if (openInput(info)) {
|
||||
opened = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opened) {
|
||||
m_lastError = QStringLiteral("Failed to start QAudioInput");
|
||||
cleanupInput();
|
||||
QFile::remove(m_path);
|
||||
m_path.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
connect(m_device, &QIODevice::readyRead, this, [this]() {
|
||||
if (!m_device || !m_file.isOpen())
|
||||
return;
|
||||
m_file.write(m_device->readAll());
|
||||
});
|
||||
|
||||
m_recording = true;
|
||||
emit recordingChanged();
|
||||
if (maxMs > 0)
|
||||
m_maxTimer.start(maxMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
QString AudioRecorder::stop()
|
||||
{
|
||||
if (!m_recording)
|
||||
return QString();
|
||||
|
||||
m_recording = false;
|
||||
emit recordingChanged();
|
||||
m_maxTimer.stop();
|
||||
|
||||
if (m_device)
|
||||
m_file.write(m_device->readAll());
|
||||
|
||||
const qint64 dataSize = qMax<qint64>(0, m_file.size() - 44);
|
||||
writeWavHeader(dataSize);
|
||||
cleanupInput();
|
||||
|
||||
// ~10ms @ 16kHz mono 16-bit; scale roughly for other rates
|
||||
const qint64 minBytes = qMax<qint64>(320, m_sampleRate * m_channels * (m_sampleSize / 8) / 100);
|
||||
if (dataSize < minBytes) {
|
||||
m_lastError = QStringLiteral("Recording too short");
|
||||
QFile::remove(m_path);
|
||||
m_path.clear();
|
||||
return QString();
|
||||
}
|
||||
return m_path;
|
||||
}
|
||||
|
||||
void AudioRecorder::cancel()
|
||||
{
|
||||
if (!m_recording && m_path.isEmpty())
|
||||
return;
|
||||
m_recording = false;
|
||||
emit recordingChanged();
|
||||
cleanupInput();
|
||||
if (!m_path.isEmpty()) {
|
||||
QFile::remove(m_path);
|
||||
m_path.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#ifndef AUDIORECORDER_H
|
||||
#define AUDIORECORDER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QAudioInput>
|
||||
#include <QAudioDeviceInfo>
|
||||
#include <QAudioFormat>
|
||||
#include <QFile>
|
||||
#include <QTimer>
|
||||
#include <QIODevice>
|
||||
#include <QList>
|
||||
|
||||
// Raw PCM WAV via QAudioInput — no gain/gate/filters (server does cleanup).
|
||||
class AudioRecorder : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool recording READ isRecording NOTIFY recordingChanged)
|
||||
|
||||
public:
|
||||
explicit AudioRecorder(QObject *parent = nullptr);
|
||||
~AudioRecorder() override;
|
||||
|
||||
bool isRecording() const { return m_recording; }
|
||||
|
||||
Q_INVOKABLE bool start(int maxMs = 30000);
|
||||
Q_INVOKABLE QString stop();
|
||||
Q_INVOKABLE void cancel();
|
||||
|
||||
QString lastError() const { return m_lastError; }
|
||||
|
||||
signals:
|
||||
void recordingChanged();
|
||||
void maxDurationReached();
|
||||
|
||||
private:
|
||||
void cleanupInput();
|
||||
void writeWavHeader(qint64 dataSize);
|
||||
bool openOutput();
|
||||
QAudioDeviceInfo pickInputDevice() const;
|
||||
QList<QAudioDeviceInfo> rankedInputDevices() const;
|
||||
static int scoreInputDevice(const QAudioDeviceInfo &info);
|
||||
bool openInput(const QAudioDeviceInfo &info);
|
||||
bool tryStartFormat(const QAudioDeviceInfo &info, const QAudioFormat &fmt);
|
||||
|
||||
QAudioInput *m_input = nullptr;
|
||||
QIODevice *m_device = nullptr;
|
||||
QFile m_file;
|
||||
QString m_path;
|
||||
QString m_lastError;
|
||||
QTimer m_maxTimer;
|
||||
bool m_recording = false;
|
||||
int m_sampleRate = 16000;
|
||||
int m_channels = 1;
|
||||
int m_sampleSize = 16;
|
||||
};
|
||||
|
||||
#endif // AUDIORECORDER_H
|
||||
+509
-24
@@ -2,9 +2,13 @@
|
||||
#include "configmanager.h"
|
||||
#include "buttonsmodel.h"
|
||||
#include "responsecheck.h"
|
||||
#include "audiorecorder.h"
|
||||
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QHttpMultiPart>
|
||||
#include <QHttpPart>
|
||||
#include <QHttpPart>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
@@ -16,6 +20,7 @@
|
||||
#include <QDebug>
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QUuid>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -116,7 +121,12 @@ ButtonController::ButtonController(ConfigManager *config, ButtonsModel *model, Q
|
||||
, m_config(config)
|
||||
, m_model(model)
|
||||
, m_nam(new QNetworkAccessManager(this))
|
||||
, m_recorder(new AudioRecorder(this))
|
||||
{
|
||||
connect(m_recorder, &AudioRecorder::maxDurationReached, this, [this]() {
|
||||
if (!m_recordingButtonId.isEmpty())
|
||||
beginVoicePostRollAndUpload(m_recordingButtonId);
|
||||
});
|
||||
}
|
||||
|
||||
ButtonController::~ButtonController()
|
||||
@@ -129,6 +139,8 @@ ButtonController::~ButtonController()
|
||||
}
|
||||
}
|
||||
m_active.clear();
|
||||
if (m_recorder && m_recorder->isRecording())
|
||||
m_recorder->cancel();
|
||||
}
|
||||
|
||||
void ButtonController::cancelActiveRequest(const QString &id)
|
||||
@@ -412,22 +424,17 @@ void ButtonController::handleReplyFinished(QNetworkReply *reply, const QString &
|
||||
|
||||
void ButtonController::invokeButton(const QString &id)
|
||||
{
|
||||
QJsonArray arr = m_config->buttons();
|
||||
QJsonObject btn;
|
||||
bool found = false;
|
||||
for (const auto &v : arr) {
|
||||
QJsonObject b = v.toObject();
|
||||
if (b.value(QStringLiteral("id")).toString() == id) {
|
||||
btn = b;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
QJsonObject btn = findButton(id);
|
||||
if (btn.isEmpty()) {
|
||||
qWarning() << "invokeButton: id not found" << id;
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn.value(QStringLiteral("kind")).toString(QStringLiteral("press")) == QLatin1String("voice")) {
|
||||
qDebug() << "invokeButton: voice button — use voice* API" << id;
|
||||
return;
|
||||
}
|
||||
|
||||
const int status = m_model->buttonStatus(id);
|
||||
const QJsonObject latchReset = btn.value(QStringLiteral("latchReset")).toObject();
|
||||
const bool latchEnabled = latchReset.value(QStringLiteral("enabled")).toBool(false);
|
||||
@@ -476,6 +483,430 @@ void ButtonController::invokeButton(const QString &id)
|
||||
startRequest(id, btn, false, false);
|
||||
}
|
||||
|
||||
QJsonObject ButtonController::findButton(const QString &id) const
|
||||
{
|
||||
QJsonArray arr = m_config->buttons();
|
||||
for (const auto &v : arr) {
|
||||
QJsonObject b = v.toObject();
|
||||
if (b.value(QStringLiteral("id")).toString() == id)
|
||||
return b;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool ButtonController::isVoiceButton(const QString &id) const
|
||||
{
|
||||
const QJsonObject btn = findButton(id);
|
||||
return btn.value(QStringLiteral("kind")).toString(QStringLiteral("press"))
|
||||
== QLatin1String("voice");
|
||||
}
|
||||
|
||||
bool ButtonController::isVoiceRecording(const QString &id) const
|
||||
{
|
||||
const VoiceState vs = m_voice.value(id);
|
||||
return vs.recording || vs.postRolling || m_recordingButtonId == id;
|
||||
}
|
||||
|
||||
bool ButtonController::startVoiceRecording(const QString &id, const QJsonObject &btn)
|
||||
{
|
||||
if (m_recorder->isRecording()) {
|
||||
qWarning() << "voice: already recording";
|
||||
return false;
|
||||
}
|
||||
const QJsonObject voice = btn.value(QStringLiteral("voice")).toObject();
|
||||
const QJsonObject feedback = btn.value(QStringLiteral("feedback")).toObject();
|
||||
const int maxMs = voice.value(QStringLiteral("maxRecordMs")).toInt(30000);
|
||||
const QString pending = feedback.value(QStringLiteral("pendingText"))
|
||||
.toString(QStringLiteral("Слушаю..."));
|
||||
|
||||
if (!m_recorder->start(maxMs > 0 ? maxMs : 30000)) {
|
||||
const QString err = feedback.value(QStringLiteral("errorText"))
|
||||
.toString(QStringLiteral("Нет микрофона"));
|
||||
m_model->setStatus(id, 2, err);
|
||||
QString idCopy = id;
|
||||
QPointer<ButtonsModel> modelPtr = m_model;
|
||||
QTimer::singleShot(3000, this, [modelPtr, idCopy]() {
|
||||
if (modelPtr) modelPtr->setStatus(idCopy, 0, QString());
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
VoiceState vs;
|
||||
vs.recording = true;
|
||||
vs.sourceId = voice.value(QStringLiteral("sourceId")).toString(id);
|
||||
vs.cancelUrl = voice.value(QStringLiteral("cancelUrl")).toString();
|
||||
vs.action = btn.value(QStringLiteral("action")).toObject();
|
||||
vs.feedback = feedback;
|
||||
int postRoll = voice.value(QStringLiteral("postRollMs")).toInt(0);
|
||||
if (postRoll < 0) postRoll = 0;
|
||||
if (postRoll > 2000) postRoll = 2000;
|
||||
vs.noiseTailMs = postRoll;
|
||||
m_voice.insert(id, vs);
|
||||
m_recordingButtonId = id;
|
||||
m_model->setStatus(id, 3, pending);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ButtonController::beginVoicePostRollAndUpload(const QString &id)
|
||||
{
|
||||
auto vit = m_voice.find(id);
|
||||
if (vit == m_voice.end())
|
||||
return;
|
||||
if (!vit->recording || vit->uploading)
|
||||
return;
|
||||
if (vit->postRolling)
|
||||
return;
|
||||
|
||||
const int ms = vit->noiseTailMs;
|
||||
if (ms <= 0) {
|
||||
stopVoiceAndUpload(id);
|
||||
return;
|
||||
}
|
||||
|
||||
vit->postRolling = true;
|
||||
++vit->postRollGen;
|
||||
const int gen = vit->postRollGen;
|
||||
m_model->setStatus(id, 3, QStringLiteral("…"));
|
||||
qInfo() << "voice: post-roll" << ms << "ms for" << id;
|
||||
|
||||
QTimer::singleShot(ms, this, [this, id, gen]() {
|
||||
auto vit = m_voice.find(id);
|
||||
if (vit == m_voice.end() || vit->postRollGen != gen)
|
||||
return;
|
||||
vit->postRolling = false;
|
||||
stopVoiceAndUpload(id);
|
||||
});
|
||||
}
|
||||
|
||||
void ButtonController::stopVoiceAndUpload(const QString &id)
|
||||
{
|
||||
if (m_recordingButtonId != id && !m_voice.value(id).recording)
|
||||
return;
|
||||
|
||||
// Invalidate pending post-roll if any
|
||||
auto vitPre = m_voice.find(id);
|
||||
if (vitPre != m_voice.end()) {
|
||||
++vitPre->postRollGen;
|
||||
vitPre->postRolling = false;
|
||||
}
|
||||
|
||||
const int noiseTailMs = m_voice.value(id).noiseTailMs;
|
||||
const QString path = m_recorder->stop();
|
||||
m_recordingButtonId.clear();
|
||||
|
||||
auto vit = m_voice.find(id);
|
||||
if (vit == m_voice.end())
|
||||
return;
|
||||
vit->recording = false;
|
||||
vit->postRolling = false;
|
||||
|
||||
const QJsonObject feedback = vit->feedback;
|
||||
const QString errorText = feedback.value(QStringLiteral("errorText"))
|
||||
.toString(QStringLiteral("Ошибка"));
|
||||
|
||||
if (path.isEmpty()) {
|
||||
m_model->setStatus(id, 2, errorText);
|
||||
m_voice.remove(id);
|
||||
QString idCopy = id;
|
||||
QPointer<ButtonsModel> modelPtr = m_model;
|
||||
QTimer::singleShot(feedback.value(QStringLiteral("fadeMs")).toInt(5000), this,
|
||||
[modelPtr, idCopy]() {
|
||||
if (modelPtr) modelPtr->setStatus(idCopy, 0, QString());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject action = vit->action;
|
||||
const QString url = action.value(QStringLiteral("url")).toString();
|
||||
if (url.isEmpty()) {
|
||||
QFile::remove(path);
|
||||
m_model->setStatus(id, 2, errorText);
|
||||
m_voice.remove(id);
|
||||
return;
|
||||
}
|
||||
|
||||
vit->uploading = true;
|
||||
m_model->setStatus(id, 3, QStringLiteral("Отправка..."));
|
||||
|
||||
auto *file = new QFile(path);
|
||||
if (!file->open(QIODevice::ReadOnly)) {
|
||||
delete file;
|
||||
QFile::remove(path);
|
||||
m_model->setStatus(id, 2, errorText);
|
||||
m_voice.remove(id);
|
||||
return;
|
||||
}
|
||||
|
||||
auto *multi = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||
|
||||
QHttpPart audioPart;
|
||||
audioPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
QVariant(QStringLiteral("form-data; name=\"audio\"; filename=\"audio.wav\"")));
|
||||
audioPart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QStringLiteral("audio/wav")));
|
||||
audioPart.setBodyDevice(file);
|
||||
file->setParent(multi);
|
||||
multi->append(audioPart);
|
||||
|
||||
QHttpPart sourcePart;
|
||||
sourcePart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
QVariant(QStringLiteral("form-data; name=\"source_id\"")));
|
||||
sourcePart.setBody(vit->sourceId.toUtf8());
|
||||
multi->append(sourcePart);
|
||||
|
||||
QHttpPart tailPart;
|
||||
tailPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
QVariant(QStringLiteral("form-data; name=\"noise_tail_ms\"")));
|
||||
tailPart.setBody(QByteArray::number(noiseTailMs));
|
||||
multi->append(tailPart);
|
||||
|
||||
QNetworkRequest req{QUrl(url)};
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
const QJsonObject headers = action.value(QStringLiteral("headers")).toObject();
|
||||
for (auto hit = headers.begin(); hit != headers.end(); ++hit)
|
||||
req.setRawHeader(hit.key().toUtf8(), hit.value().toString().toUtf8());
|
||||
|
||||
const int timeoutMs = action.value(QStringLiteral("timeoutMs")).toInt(60000);
|
||||
QNetworkReply *reply = m_nam->post(req, multi);
|
||||
multi->setParent(reply);
|
||||
|
||||
QTimer *timeout = new QTimer(reply);
|
||||
timeout->setSingleShot(true);
|
||||
timeout->start(timeoutMs > 0 ? timeoutMs : 60000);
|
||||
connect(timeout, &QTimer::timeout, reply, [reply]() {
|
||||
if (reply && reply->isRunning())
|
||||
reply->abort();
|
||||
});
|
||||
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, id, path]() {
|
||||
QFile::remove(path);
|
||||
handleVoiceClassifyReply(reply, id);
|
||||
reply->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void ButtonController::handleVoiceClassifyReply(QNetworkReply *reply, const QString &id)
|
||||
{
|
||||
auto vit = m_voice.find(id);
|
||||
if (vit == m_voice.end())
|
||||
return;
|
||||
vit->uploading = false;
|
||||
|
||||
const QJsonObject feedback = vit->feedback;
|
||||
const QString errorText = feedback.value(QStringLiteral("errorText"))
|
||||
.toString(QStringLiteral("Ошибка"));
|
||||
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QByteArray body = reply->readAll();
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError || httpStatus >= 400) {
|
||||
QString msg = errorText;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(body);
|
||||
if (doc.isObject()) {
|
||||
const QString m = doc.object().value(QStringLiteral("message")).toString();
|
||||
if (!m.isEmpty()) msg = m;
|
||||
}
|
||||
logButtonResult(id, QStringLiteral("POST"),
|
||||
vit->action.value(QStringLiteral("url")).toString(),
|
||||
false, httpStatus, 0, msg, 0, QStringLiteral("error"));
|
||||
m_model->setStatus(id, 2, msg);
|
||||
m_voice.remove(id);
|
||||
QString idCopy = id;
|
||||
QPointer<ButtonsModel> modelPtr = m_model;
|
||||
const int fade = feedback.value(QStringLiteral("fadeMs")).toInt(5000);
|
||||
QTimer::singleShot(fade, this, [modelPtr, idCopy]() {
|
||||
if (modelPtr) modelPtr->setStatus(idCopy, 0, QString());
|
||||
});
|
||||
emit buttonInvoked(id, false, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonDocument doc = QJsonDocument::fromJson(body);
|
||||
QJsonObject obj = doc.object();
|
||||
const QString status = obj.value(QStringLiteral("status")).toString();
|
||||
if (status == QLatin1String("error")) {
|
||||
const QString msg = obj.value(QStringLiteral("message")).toString(errorText);
|
||||
m_model->setStatus(id, 2, msg);
|
||||
m_voice.remove(id);
|
||||
QString idCopy = id;
|
||||
QPointer<ButtonsModel> modelPtr = m_model;
|
||||
QTimer::singleShot(feedback.value(QStringLiteral("fadeMs")).toInt(5000), this,
|
||||
[modelPtr, idCopy]() {
|
||||
if (modelPtr) modelPtr->setStatus(idCopy, 0, QString());
|
||||
});
|
||||
emit buttonInvoked(id, false, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Warning (silence / unmatched): show message, auto-fade, no cancel wait
|
||||
const bool needsCancel = obj.value(QStringLiteral("needs_cancel")).toBool(
|
||||
status == QLatin1String("success"));
|
||||
if (status == QLatin1String("warning") || !needsCancel) {
|
||||
const QString category = obj.value(QStringLiteral("category")).toString();
|
||||
const QString msg = obj.value(QStringLiteral("message")).toString(
|
||||
category.isEmpty() ? QStringLiteral("Не распознано") : category);
|
||||
logButtonResult(id, QStringLiteral("POST"),
|
||||
vit->action.value(QStringLiteral("url")).toString(),
|
||||
false, httpStatus, 0, msg, 0, QStringLiteral("warning"), category);
|
||||
m_model->setStatus(id, 2, msg);
|
||||
m_voice.remove(id);
|
||||
QString idCopy = id;
|
||||
QPointer<ButtonsModel> modelPtr = m_model;
|
||||
QTimer::singleShot(feedback.value(QStringLiteral("fadeMs")).toInt(5000), this,
|
||||
[modelPtr, idCopy]() {
|
||||
if (modelPtr) modelPtr->setStatus(idCopy, 0, QString());
|
||||
});
|
||||
emit buttonInvoked(id, false, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString category = obj.value(QStringLiteral("category")).toString();
|
||||
const QString requestId = obj.value(QStringLiteral("request_id")).toString();
|
||||
vit->requestId = requestId;
|
||||
vit->category = category;
|
||||
vit->recording = false;
|
||||
vit->uploading = false;
|
||||
|
||||
const QString successOverride = feedback.value(QStringLiteral("successText")).toString();
|
||||
const QString display = !successOverride.isEmpty() ? successOverride
|
||||
: (!category.isEmpty() ? category : QStringLiteral("OK"));
|
||||
|
||||
logButtonResult(id, QStringLiteral("POST"),
|
||||
vit->action.value(QStringLiteral("url")).toString(),
|
||||
true, httpStatus, 0, display, 0, QStringLiteral("ok"), category);
|
||||
|
||||
// Stay lit until cancel (like latch).
|
||||
m_model->setStatus(id, 1, display);
|
||||
emit buttonInvoked(id, true, display);
|
||||
}
|
||||
|
||||
void ButtonController::sendVoiceCancel(const QString &id)
|
||||
{
|
||||
auto vit = m_voice.find(id);
|
||||
const QString cancelUrl = vit != m_voice.end()
|
||||
? vit->cancelUrl
|
||||
: findButton(id).value(QStringLiteral("voice")).toObject()
|
||||
.value(QStringLiteral("cancelUrl")).toString();
|
||||
const QString requestId = vit != m_voice.end() ? vit->requestId : QString();
|
||||
const QString sourceId = vit != m_voice.end() ? vit->sourceId
|
||||
: findButton(id).value(QStringLiteral("voice")).toObject()
|
||||
.value(QStringLiteral("sourceId")).toString(id);
|
||||
QJsonObject action = vit != m_voice.end() ? vit->action
|
||||
: findButton(id).value(QStringLiteral("action")).toObject();
|
||||
|
||||
m_model->setStatus(id, 0, QString());
|
||||
m_voice.remove(id);
|
||||
|
||||
if (cancelUrl.isEmpty()) {
|
||||
emit buttonInvoked(id, true, QStringLiteral("cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkRequest req{QUrl(cancelUrl)};
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
const QJsonObject headers = action.value(QStringLiteral("headers")).toObject();
|
||||
for (auto hit = headers.begin(); hit != headers.end(); ++hit)
|
||||
req.setRawHeader(hit.key().toUtf8(), hit.value().toString().toUtf8());
|
||||
|
||||
QJsonObject body;
|
||||
body.insert(QStringLiteral("request_id"), requestId);
|
||||
body.insert(QStringLiteral("source_id"), sourceId);
|
||||
QNetworkReply *reply = m_nam->post(req, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||
|
||||
QTimer *timeout = new QTimer(reply);
|
||||
timeout->setSingleShot(true);
|
||||
timeout->start(10000);
|
||||
connect(timeout, &QTimer::timeout, reply, [reply]() {
|
||||
if (reply && reply->isRunning())
|
||||
reply->abort();
|
||||
});
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, id]() {
|
||||
handleVoiceCancelReply(reply, id);
|
||||
reply->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void ButtonController::handleVoiceCancelReply(QNetworkReply *reply, const QString &id)
|
||||
{
|
||||
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const bool ok = reply->error() == QNetworkReply::NoError && httpStatus < 400;
|
||||
logButtonResult(id, QStringLiteral("POST"), reply->url().toString(),
|
||||
ok, httpStatus, 0, ok ? QStringLiteral("cancelled") : reply->errorString(),
|
||||
0, ok ? QStringLiteral("ok") : QStringLiteral("error"));
|
||||
emit buttonInvoked(id, ok, ok ? QStringLiteral("cancelled") : reply->errorString());
|
||||
}
|
||||
|
||||
void ButtonController::voicePointerPressed(const QString &id)
|
||||
{
|
||||
if (!isVoiceButton(id))
|
||||
return;
|
||||
const QJsonObject btn = findButton(id);
|
||||
const QString mode = btn.value(QStringLiteral("trigger")).toObject()
|
||||
.value(QStringLiteral("mode")).toString(QStringLiteral("hold"));
|
||||
const int status = m_model->buttonStatus(id);
|
||||
|
||||
if (status == 1) {
|
||||
// Cancel: both short press and hold — arm on press, fire on release.
|
||||
VoiceState &vs = m_voice[id];
|
||||
vs.cancelArmed = true;
|
||||
return;
|
||||
}
|
||||
if (status == 2 || status == 3)
|
||||
return;
|
||||
|
||||
if (mode == QLatin1String("hold") && status == 0)
|
||||
startVoiceRecording(id, btn);
|
||||
}
|
||||
|
||||
void ButtonController::voicePointerReleased(const QString &id)
|
||||
{
|
||||
if (!isVoiceButton(id))
|
||||
return;
|
||||
const QJsonObject btn = findButton(id);
|
||||
const QString mode = btn.value(QStringLiteral("trigger")).toObject()
|
||||
.value(QStringLiteral("mode")).toString(QStringLiteral("hold"));
|
||||
|
||||
auto vit = m_voice.find(id);
|
||||
if (vit != m_voice.end() && vit->cancelArmed) {
|
||||
vit->cancelArmed = false;
|
||||
if (m_model->buttonStatus(id) == 1)
|
||||
sendVoiceCancel(id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == QLatin1String("hold") && isVoiceRecording(id))
|
||||
beginVoicePostRollAndUpload(id);
|
||||
}
|
||||
|
||||
void ButtonController::voiceClicked(const QString &id)
|
||||
{
|
||||
if (!isVoiceButton(id))
|
||||
return;
|
||||
const QJsonObject btn = findButton(id);
|
||||
const QString mode = btn.value(QStringLiteral("trigger")).toObject()
|
||||
.value(QStringLiteral("mode")).toString(QStringLiteral("hold"));
|
||||
|
||||
const int status = m_model->buttonStatus(id);
|
||||
// Cancel by click also works in hold-recording mode when button is lit
|
||||
// (if release already cancelled, status is 0 and this is a no-op).
|
||||
if (status == 1) {
|
||||
sendVoiceCancel(id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode != QLatin1String("click"))
|
||||
return;
|
||||
|
||||
if (status == 2 || (status == 3 && m_voice.value(id).uploading))
|
||||
return;
|
||||
if (status == 3 && m_voice.value(id).postRolling)
|
||||
return;
|
||||
|
||||
if (isVoiceRecording(id))
|
||||
beginVoicePostRollAndUpload(id);
|
||||
else if (status == 0)
|
||||
startVoiceRecording(id, btn);
|
||||
}
|
||||
|
||||
bool ButtonController::checkPassword(const QString &pwd) const
|
||||
{
|
||||
QString stored = m_config->settingsObj().value("password").toString("admin");
|
||||
@@ -508,14 +939,26 @@ void ButtonController::addButton(const QString &label,
|
||||
bool latchEnabled,
|
||||
const QString &resetUrl,
|
||||
const QString &successMatch,
|
||||
bool fireAndForget)
|
||||
bool fireAndForget,
|
||||
const QString &kind,
|
||||
const QString &cancelUrl,
|
||||
const QString &sourceId,
|
||||
int maxRecordMs,
|
||||
const QString &apiKey)
|
||||
{
|
||||
const bool isVoice = kind == QLatin1String("voice");
|
||||
QJsonObject action;
|
||||
action.insert("type", actionType.isEmpty() ? "http_get" : actionType);
|
||||
action.insert("type", isVoice ? QStringLiteral("http_post")
|
||||
: (actionType.isEmpty() ? "http_get" : actionType));
|
||||
action.insert("url", actionUrl);
|
||||
action.insert("headers", QJsonObject());
|
||||
QJsonObject headers;
|
||||
if (!apiKey.trimmed().isEmpty())
|
||||
headers.insert(QStringLiteral("X-API-Key"), apiKey.trimmed());
|
||||
action.insert("headers", headers);
|
||||
action.insert("body", "");
|
||||
if (latchEnabled) {
|
||||
if (isVoice)
|
||||
action.insert(QStringLiteral("timeoutMs"), 60000);
|
||||
if (latchEnabled && !isVoice) {
|
||||
QJsonObject responseCheck;
|
||||
responseCheck.insert(QStringLiteral("enabled"), false);
|
||||
action.insert(QStringLiteral("responseCheck"), responseCheck);
|
||||
@@ -524,7 +967,10 @@ void ButtonController::addButton(const QString &label,
|
||||
QJsonObject feedback;
|
||||
feedback.insert("successText", successText);
|
||||
feedback.insert("errorText", errorText);
|
||||
feedback.insert("pendingText", pendingText.isEmpty() ? QStringLiteral("...") : pendingText);
|
||||
feedback.insert("pendingText",
|
||||
pendingText.isEmpty()
|
||||
? (isVoice ? QStringLiteral("Слушаю...") : QStringLiteral("..."))
|
||||
: pendingText);
|
||||
feedback.insert("fadeMs", 5000);
|
||||
|
||||
QJsonObject trigger;
|
||||
@@ -535,11 +981,22 @@ void ButtonController::addButton(const QString &label,
|
||||
btn.insert("label", label);
|
||||
btn.insert("iconPath", iconPath);
|
||||
btn.insert("color", color.isEmpty() ? "#7b007b" : color);
|
||||
btn.insert(QStringLiteral("kind"), isVoice ? QStringLiteral("voice") : QStringLiteral("press"));
|
||||
btn.insert("action", action);
|
||||
btn.insert("feedback", feedback);
|
||||
btn.insert("trigger", trigger);
|
||||
btn.insert(QStringLiteral("latchReset"),
|
||||
makeLatchReset(latchEnabled, resetUrl, successMatch, fireAndForget));
|
||||
if (isVoice) {
|
||||
QJsonObject voice;
|
||||
voice.insert(QStringLiteral("cancelUrl"), cancelUrl);
|
||||
voice.insert(QStringLiteral("sourceId"),
|
||||
sourceId.isEmpty() ? QStringLiteral("panel-01") : sourceId);
|
||||
voice.insert(QStringLiteral("maxRecordMs"), maxRecordMs > 0 ? maxRecordMs : 30000);
|
||||
btn.insert(QStringLiteral("voice"), voice);
|
||||
btn.insert(QStringLiteral("latchReset"), makeLatchReset(false, QString(), QString()));
|
||||
} else {
|
||||
btn.insert(QStringLiteral("latchReset"),
|
||||
makeLatchReset(latchEnabled, resetUrl, successMatch, fireAndForget));
|
||||
}
|
||||
m_config->addButton(btn);
|
||||
}
|
||||
|
||||
@@ -557,16 +1014,32 @@ void ButtonController::updateButton(const QString &id,
|
||||
bool latchEnabled,
|
||||
const QString &resetUrl,
|
||||
const QString &successMatch,
|
||||
bool fireAndForget)
|
||||
bool fireAndForget,
|
||||
const QString &kind,
|
||||
const QString &cancelUrl,
|
||||
const QString &sourceId,
|
||||
int maxRecordMs,
|
||||
const QString &apiKey)
|
||||
{
|
||||
const bool isVoice = kind == QLatin1String("voice");
|
||||
QJsonArray arr = m_config->buttons();
|
||||
for (const auto &v : arr) {
|
||||
QJsonObject b = v.toObject();
|
||||
if (b.value("id").toString() != id) continue;
|
||||
QJsonObject action = b.value("action").toObject();
|
||||
action.insert("type", actionType.isEmpty() ? "http_get" : actionType);
|
||||
action.insert("type", isVoice ? QStringLiteral("http_post")
|
||||
: (actionType.isEmpty() ? "http_get" : actionType));
|
||||
action.insert("url", actionUrl);
|
||||
if (latchEnabled) {
|
||||
QJsonObject headers = action.value(QStringLiteral("headers")).toObject();
|
||||
if (apiKey.trimmed().isEmpty())
|
||||
headers.remove(QStringLiteral("X-API-Key"));
|
||||
else
|
||||
headers.insert(QStringLiteral("X-API-Key"), apiKey.trimmed());
|
||||
action.insert(QStringLiteral("headers"), headers);
|
||||
if (isVoice)
|
||||
action.insert(QStringLiteral("timeoutMs"),
|
||||
action.value(QStringLiteral("timeoutMs")).toInt(60000));
|
||||
if (latchEnabled && !isVoice) {
|
||||
QJsonObject responseCheck = action.value(QStringLiteral("responseCheck")).toObject();
|
||||
responseCheck.insert(QStringLiteral("enabled"), false);
|
||||
action.insert(QStringLiteral("responseCheck"), responseCheck);
|
||||
@@ -582,11 +1055,23 @@ void ButtonController::updateButton(const QString &id,
|
||||
b.insert("label", label);
|
||||
b.insert("iconPath", iconPath);
|
||||
b.insert("color", color.isEmpty() ? "#7b007b" : color);
|
||||
b.insert(QStringLiteral("kind"), isVoice ? QStringLiteral("voice") : QStringLiteral("press"));
|
||||
b.insert("action", action);
|
||||
b.insert("feedback", feedback);
|
||||
b.insert("trigger", trigger);
|
||||
b.insert(QStringLiteral("latchReset"),
|
||||
makeLatchReset(latchEnabled, resetUrl, successMatch, fireAndForget));
|
||||
if (isVoice) {
|
||||
QJsonObject voice = b.value(QStringLiteral("voice")).toObject();
|
||||
voice.insert(QStringLiteral("cancelUrl"), cancelUrl);
|
||||
voice.insert(QStringLiteral("sourceId"),
|
||||
sourceId.isEmpty() ? id : sourceId);
|
||||
voice.insert(QStringLiteral("maxRecordMs"), maxRecordMs > 0 ? maxRecordMs : 30000);
|
||||
b.insert(QStringLiteral("voice"), voice);
|
||||
b.insert(QStringLiteral("latchReset"), makeLatchReset(false, QString(), QString()));
|
||||
} else {
|
||||
b.remove(QStringLiteral("voice"));
|
||||
b.insert(QStringLiteral("latchReset"),
|
||||
makeLatchReset(latchEnabled, resetUrl, successMatch, fireAndForget));
|
||||
}
|
||||
m_config->updateButton(id, b);
|
||||
return;
|
||||
}
|
||||
|
||||
+46
-2
@@ -9,6 +9,7 @@
|
||||
|
||||
class ConfigManager;
|
||||
class ButtonsModel;
|
||||
class AudioRecorder;
|
||||
class QNetworkReply;
|
||||
class QTimer;
|
||||
|
||||
@@ -23,6 +24,13 @@ public:
|
||||
Q_INVOKABLE void invokeButton(const QString &id);
|
||||
Q_INVOKABLE bool checkPassword(const QString &pwd) const;
|
||||
|
||||
// Voice button API (kind == "voice")
|
||||
Q_INVOKABLE bool isVoiceButton(const QString &id) const;
|
||||
Q_INVOKABLE bool isVoiceRecording(const QString &id) const;
|
||||
Q_INVOKABLE void voicePointerPressed(const QString &id);
|
||||
Q_INVOKABLE void voicePointerReleased(const QString &id);
|
||||
Q_INVOKABLE void voiceClicked(const QString &id);
|
||||
|
||||
// CRUD wrappers (so QML can call without touching ConfigManager directly)
|
||||
Q_INVOKABLE void addButton(const QString &label,
|
||||
const QString &iconPath,
|
||||
@@ -37,7 +45,12 @@ public:
|
||||
bool latchEnabled = true,
|
||||
const QString &resetUrl = QString(),
|
||||
const QString &successMatch = QStringLiteral("OK"),
|
||||
bool fireAndForget = false);
|
||||
bool fireAndForget = false,
|
||||
const QString &kind = QStringLiteral("press"),
|
||||
const QString &cancelUrl = QString(),
|
||||
const QString &sourceId = QString(),
|
||||
int maxRecordMs = 30000,
|
||||
const QString &apiKey = QString());
|
||||
Q_INVOKABLE void updateButton(const QString &id,
|
||||
const QString &label,
|
||||
const QString &iconPath,
|
||||
@@ -52,7 +65,12 @@ public:
|
||||
bool latchEnabled = false,
|
||||
const QString &resetUrl = QString(),
|
||||
const QString &successMatch = QStringLiteral("OK"),
|
||||
bool fireAndForget = false);
|
||||
bool fireAndForget = false,
|
||||
const QString &kind = QStringLiteral("press"),
|
||||
const QString &cancelUrl = QString(),
|
||||
const QString &sourceId = QString(),
|
||||
int maxRecordMs = 30000,
|
||||
const QString &apiKey = QString());
|
||||
Q_INVOKABLE void setResponseCheckEnabled(const QString &id, bool enabled);
|
||||
Q_INVOKABLE void removeButton(const QString &id);
|
||||
Q_INVOKABLE void moveButton(int from, int to);
|
||||
@@ -102,6 +120,22 @@ private:
|
||||
QTimer *pollTimer = nullptr;
|
||||
};
|
||||
|
||||
struct VoiceState {
|
||||
QString requestId;
|
||||
QString sourceId;
|
||||
QString cancelUrl;
|
||||
QString category;
|
||||
QJsonObject action;
|
||||
QJsonObject feedback;
|
||||
bool recording = false;
|
||||
bool uploading = false;
|
||||
bool cancelArmed = false; // pressed while lit, cancel on release
|
||||
bool postRolling = false; // capturing silence tail after release
|
||||
int noiseTailMs = 0;
|
||||
int postRollGen = 0;
|
||||
};
|
||||
|
||||
QJsonObject findButton(const QString &id) const;
|
||||
void cancelActiveRequest(const QString &id);
|
||||
void startRequest(const QString &id, const QJsonObject &btn, bool isPoll, bool isReset = false);
|
||||
void schedulePoll(const QString &id, const QString &message);
|
||||
@@ -110,10 +144,20 @@ private:
|
||||
const QString &matchedRule = QString());
|
||||
void handleReplyFinished(QNetworkReply *reply, const QString &id, int generation);
|
||||
|
||||
bool startVoiceRecording(const QString &id, const QJsonObject &btn);
|
||||
void beginVoicePostRollAndUpload(const QString &id);
|
||||
void stopVoiceAndUpload(const QString &id);
|
||||
void sendVoiceCancel(const QString &id);
|
||||
void handleVoiceClassifyReply(QNetworkReply *reply, const QString &id);
|
||||
void handleVoiceCancelReply(QNetworkReply *reply, const QString &id);
|
||||
|
||||
ConfigManager *m_config;
|
||||
ButtonsModel *m_model;
|
||||
QNetworkAccessManager *m_nam;
|
||||
AudioRecorder *m_recorder;
|
||||
QString m_recordingButtonId;
|
||||
QHash<QString, RequestState> m_active;
|
||||
QHash<QString, VoiceState> m_voice;
|
||||
};
|
||||
|
||||
#endif // BUTTONCONTROLLER_H
|
||||
|
||||
+15
-1
@@ -45,6 +45,7 @@ QVariant ButtonsModel::data(const QModelIndex &index, int role) const
|
||||
QJsonObject feedback = b.value("feedback").toObject();
|
||||
QJsonObject trigger = b.value("trigger").toObject();
|
||||
QJsonObject latchReset = b.value("latchReset").toObject();
|
||||
QJsonObject voice = b.value(QStringLiteral("voice")).toObject();
|
||||
RuntimeState rt = m_runtime.value(id);
|
||||
|
||||
switch (role) {
|
||||
@@ -83,6 +84,14 @@ QVariant ButtonsModel::data(const QModelIndex &index, int role) const
|
||||
case BtnColorRole: return b.value("color").toString("#7b007b");
|
||||
case TriggerModeRole: return trigger.value("mode").toString("hold");
|
||||
case HoldMsRole: return trigger.value("holdMs").toInt(800);
|
||||
case KindRole: return b.value(QStringLiteral("kind")).toString(QStringLiteral("press"));
|
||||
case VoiceCancelUrlRole: return voice.value(QStringLiteral("cancelUrl")).toString();
|
||||
case VoiceSourceIdRole: return voice.value(QStringLiteral("sourceId")).toString();
|
||||
case VoiceMaxRecordMsRole: return voice.value(QStringLiteral("maxRecordMs")).toInt(30000);
|
||||
case ApiKeyRole: {
|
||||
const QJsonObject headers = action.value(QStringLiteral("headers")).toObject();
|
||||
return headers.value(QStringLiteral("X-API-Key")).toString();
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -110,7 +119,12 @@ QHash<int, QByteArray> ButtonsModel::roleNames() const
|
||||
{StatusTextRole, "statusText"},
|
||||
{BtnColorRole, "btnColor"},
|
||||
{TriggerModeRole, "triggerMode"},
|
||||
{HoldMsRole, "holdMs"}
|
||||
{HoldMsRole, "holdMs"},
|
||||
{KindRole, "kind"},
|
||||
{VoiceCancelUrlRole, "voiceCancelUrl"},
|
||||
{VoiceSourceIdRole, "voiceSourceId"},
|
||||
{VoiceMaxRecordMsRole, "voiceMaxRecordMs"},
|
||||
{ApiKeyRole, "apiKey"}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -34,7 +34,12 @@ public:
|
||||
StatusTextRole,
|
||||
BtnColorRole,
|
||||
TriggerModeRole, // "hold" | "click"
|
||||
HoldMsRole
|
||||
HoldMsRole,
|
||||
KindRole, // "press" | "voice"
|
||||
VoiceCancelUrlRole,
|
||||
VoiceSourceIdRole,
|
||||
VoiceMaxRecordMsRole,
|
||||
ApiKeyRole // action.headers["X-API-Key"]
|
||||
};
|
||||
|
||||
explicit ButtonsModel(ConfigManager *config, QObject *parent = nullptr);
|
||||
|
||||
+71
-6
@@ -98,10 +98,12 @@ QVariantList SystemInfo::networkInfo() const
|
||||
const QStringList parts = line.split(':');
|
||||
if (parts.size() < 4)
|
||||
continue;
|
||||
if (parts.at(1) != "ethernet")
|
||||
const QString dtype = parts.at(1);
|
||||
if (dtype != QLatin1String("ethernet") && dtype != QLatin1String("wifi"))
|
||||
continue;
|
||||
QVariantMap dev;
|
||||
dev.insert("device", parts.at(0));
|
||||
dev.insert(QStringLiteral("type"), dtype);
|
||||
dev.insert("state", parts.at(2));
|
||||
dev.insert("connection", parts.at(3));
|
||||
|
||||
@@ -152,12 +154,15 @@ QVariantMap SystemInfo::networkConfig() const
|
||||
if (!dev.waitForStarted(1500) || !dev.waitForFinished(3000))
|
||||
return result;
|
||||
QString iface;
|
||||
QString detectedType = QStringLiteral("ethernet");
|
||||
const QStringList devices =
|
||||
QString::fromUtf8(dev.readAllStandardOutput()).split('\n', QString::SkipEmptyParts);
|
||||
for (const QString &line : devices) {
|
||||
const QStringList parts = line.split(':');
|
||||
if (parts.size() >= 2 && parts.at(1) == QLatin1String("ethernet")) {
|
||||
if (parts.size() >= 2 && (parts.at(1) == QLatin1String("ethernet")
|
||||
|| parts.at(1) == QLatin1String("wifi"))) {
|
||||
iface = parts.at(0);
|
||||
detectedType = parts.at(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -188,6 +193,7 @@ QVariantMap SystemInfo::networkConfig() const
|
||||
return result;
|
||||
|
||||
result.insert("iface", iface);
|
||||
result.insert(QStringLiteral("type"), detectedType);
|
||||
const QStringList lines =
|
||||
QString::fromUtf8(cfg.readAllStandardOutput()).split('\n', QString::SkipEmptyParts);
|
||||
for (const QString &line : lines) {
|
||||
@@ -215,11 +221,19 @@ QVariantMap SystemInfo::networkConfig() const
|
||||
static void saveNetworkConfig(const QString &root,
|
||||
const QString &iface, const QString &mode,
|
||||
const QString &address, int prefix,
|
||||
const QString &gateway, const QString &dns)
|
||||
const QString &gateway, const QString &dns,
|
||||
const QString &netType = QStringLiteral("ethernet"),
|
||||
const QString &ssid = QString(),
|
||||
const QString &password = QString())
|
||||
{
|
||||
QJsonObject obj;
|
||||
obj.insert(QStringLiteral("iface"), iface);
|
||||
obj.insert(QStringLiteral("mode"), mode);
|
||||
obj.insert(QStringLiteral("type"), netType.isEmpty() ? QStringLiteral("ethernet") : netType);
|
||||
if (netType == QLatin1String("wifi")) {
|
||||
obj.insert(QStringLiteral("ssid"), ssid);
|
||||
obj.insert(QStringLiteral("password"), password);
|
||||
}
|
||||
if (mode == QLatin1String("static")) {
|
||||
obj.insert(QStringLiteral("address"), address);
|
||||
obj.insert(QStringLiteral("prefix"), prefix);
|
||||
@@ -240,7 +254,10 @@ QVariantMap SystemInfo::applyNetwork(const QString &iface,
|
||||
const QString &address,
|
||||
int prefix,
|
||||
const QString &gateway,
|
||||
const QString &dns)
|
||||
const QString &dns,
|
||||
const QString &netType,
|
||||
const QString &ssid,
|
||||
const QString &password)
|
||||
{
|
||||
QVariantMap res;
|
||||
res.insert("ok", false);
|
||||
@@ -255,6 +272,15 @@ QVariantMap SystemInfo::applyNetwork(const QString &iface,
|
||||
res.insert("error", "Режим должен быть dhcp или static");
|
||||
return res;
|
||||
}
|
||||
const QString type = netType.isEmpty() ? QStringLiteral("ethernet") : netType;
|
||||
if (type != QLatin1String("ethernet") && type != QLatin1String("wifi")) {
|
||||
res.insert("error", "Тип должен быть ethernet или wifi");
|
||||
return res;
|
||||
}
|
||||
if (type == QLatin1String("wifi") && ssid.trimmed().isEmpty()) {
|
||||
res.insert("error", "Укажите SSID Wi‑Fi");
|
||||
return res;
|
||||
}
|
||||
|
||||
QStringList args;
|
||||
args << "-n" << (scriptsDir() + "/bt-netconfig") << iface << mode;
|
||||
@@ -275,6 +301,8 @@ QVariantMap SystemInfo::applyNetwork(const QString &iface,
|
||||
}
|
||||
args << address << QString::number(prefix) << gateway << dns;
|
||||
}
|
||||
if (type == QLatin1String("wifi"))
|
||||
args << QStringLiteral("wifi") << ssid << password;
|
||||
|
||||
QProcess proc;
|
||||
proc.start("sudo", args);
|
||||
@@ -282,7 +310,7 @@ QVariantMap SystemInfo::applyNetwork(const QString &iface,
|
||||
res.insert("error", "Не удалось запустить sudo/bt-netconfig");
|
||||
return res;
|
||||
}
|
||||
if (!proc.waitForFinished(30000)) {
|
||||
if (!proc.waitForFinished(60000)) {
|
||||
proc.kill();
|
||||
res.insert("error", "Таймаут применения настроек");
|
||||
return res;
|
||||
@@ -293,7 +321,44 @@ QVariantMap SystemInfo::applyNetwork(const QString &iface,
|
||||
res.insert("error", errOut.isEmpty() ? out : errOut);
|
||||
return res;
|
||||
}
|
||||
saveNetworkConfig(installRoot(), iface, mode, address, prefix, gateway, dns);
|
||||
saveNetworkConfig(installRoot(), iface, mode, address, prefix, gateway, dns,
|
||||
type, ssid, password);
|
||||
res.insert("ok", true);
|
||||
res.insert("output", out);
|
||||
return res;
|
||||
}
|
||||
|
||||
QVariantMap SystemInfo::disconnectNetwork(const QString &iface)
|
||||
{
|
||||
QVariantMap res;
|
||||
res.insert("ok", false);
|
||||
|
||||
QRegExp ifaceRe("^[A-Za-z0-9_.:-]{1,32}$");
|
||||
if (!ifaceRe.exactMatch(iface)) {
|
||||
res.insert("error", "Некорректное имя интерфейса");
|
||||
return res;
|
||||
}
|
||||
|
||||
QStringList args;
|
||||
args << "-n" << (scriptsDir() + "/bt-netconfig") << iface << QStringLiteral("disconnect");
|
||||
|
||||
QProcess proc;
|
||||
proc.start("sudo", args);
|
||||
if (!proc.waitForStarted(3000)) {
|
||||
res.insert("error", "Не удалось запустить sudo/bt-netconfig");
|
||||
return res;
|
||||
}
|
||||
if (!proc.waitForFinished(30000)) {
|
||||
proc.kill();
|
||||
res.insert("error", "Таймаут отключения");
|
||||
return res;
|
||||
}
|
||||
const QString out = QString::fromUtf8(proc.readAllStandardOutput()).trimmed();
|
||||
const QString errOut = QString::fromUtf8(proc.readAllStandardError()).trimmed();
|
||||
if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) {
|
||||
res.insert("error", errOut.isEmpty() ? out : errOut);
|
||||
return res;
|
||||
}
|
||||
res.insert("ok", true);
|
||||
res.insert("output", out);
|
||||
return res;
|
||||
|
||||
+5
-1
@@ -31,7 +31,11 @@ public:
|
||||
const QString &address = QString(),
|
||||
int prefix = 24,
|
||||
const QString &gateway = QString(),
|
||||
const QString &dns = QString());
|
||||
const QString &dns = QString(),
|
||||
const QString &netType = QStringLiteral("ethernet"),
|
||||
const QString &ssid = QString(),
|
||||
const QString &password = QString());
|
||||
Q_INVOKABLE QVariantMap disconnectNetwork(const QString &iface);
|
||||
Q_INVOKABLE QVariantMap updateStatus() const;
|
||||
Q_INVOKABLE QVariantMap brightnessStatus() const;
|
||||
Q_INVOKABLE QVariantMap setBrightness(int percent);
|
||||
|
||||
+47
-1
@@ -116,11 +116,55 @@ def _apply_button_latch_reset(btn, data):
|
||||
return btn
|
||||
|
||||
|
||||
def _apply_button_voice(btn, data):
|
||||
"""Apply kind + voice block. Voice buttons disable latch."""
|
||||
kind = (data.get("kind") or btn.get("kind") or "press").strip().lower()
|
||||
if kind not in ("press", "voice"):
|
||||
abort(400, "kind must be press or voice")
|
||||
btn["kind"] = kind
|
||||
if kind != "voice":
|
||||
btn.pop("voice", None)
|
||||
return btn
|
||||
|
||||
voice_in = data.get("voice") if isinstance(data.get("voice"), dict) else {}
|
||||
cancel_url = (voice_in.get("cancelUrl") or data.get("cancelUrl") or "").strip()
|
||||
source_id = (voice_in.get("sourceId") or data.get("sourceId") or "").strip()
|
||||
try:
|
||||
max_record_ms = int(voice_in.get("maxRecordMs", data.get("maxRecordMs", 30000)))
|
||||
except (TypeError, ValueError):
|
||||
abort(400, "maxRecordMs must be an integer")
|
||||
if max_record_ms < 1000 or max_record_ms > 120000:
|
||||
abort(400, "maxRecordMs must be 1000..120000")
|
||||
|
||||
btn["voice"] = {
|
||||
"cancelUrl": cancel_url,
|
||||
"sourceId": source_id or btn.get("id") or "panel-01",
|
||||
"maxRecordMs": max_record_ms,
|
||||
}
|
||||
# Voice uses its own cancel flow — disable latch.
|
||||
btn["latchReset"] = {"enabled": False}
|
||||
action = btn.setdefault("action", {})
|
||||
action["type"] = "http_post"
|
||||
if "timeoutMs" not in action:
|
||||
action["timeoutMs"] = 60000
|
||||
return btn
|
||||
|
||||
|
||||
def _apply_button_action(action, data):
|
||||
action["type"] = data.get("actionType", action.get("type", "http_get"))
|
||||
action["url"] = data.get("url", action.get("url", ""))
|
||||
if "headers" in data:
|
||||
action["headers"] = data["headers"]
|
||||
action["headers"] = data["headers"] if isinstance(data["headers"], dict) else {}
|
||||
if "apiKey" in data:
|
||||
headers = action.setdefault("headers", {})
|
||||
if not isinstance(headers, dict):
|
||||
headers = {}
|
||||
action["headers"] = headers
|
||||
key = (data.get("apiKey") or "").strip()
|
||||
if key:
|
||||
headers["X-API-Key"] = key
|
||||
else:
|
||||
headers.pop("X-API-Key", None)
|
||||
if "body" in data:
|
||||
action["body"] = data["body"]
|
||||
if "timeoutMs" in data:
|
||||
@@ -195,6 +239,7 @@ def api_add_button():
|
||||
"color": data.get("color", "#7b007b"),
|
||||
}
|
||||
_apply_button_latch_reset(btn, data)
|
||||
_apply_button_voice(btn, data)
|
||||
cfg.setdefault("buttons", []).append(btn)
|
||||
save_config(cfg)
|
||||
return jsonify(btn)
|
||||
@@ -228,6 +273,7 @@ def api_update_button(bid):
|
||||
if "color" in data:
|
||||
b["color"] = data["color"]
|
||||
_apply_button_latch_reset(b, data)
|
||||
_apply_button_voice(b, data)
|
||||
save_config(cfg)
|
||||
return jsonify(b)
|
||||
abort(404)
|
||||
|
||||
@@ -7,7 +7,7 @@ Wants=NetworkManager.service
|
||||
Type=simple
|
||||
User=buttontask
|
||||
Group=buttontask
|
||||
SupplementaryGroups=video render input
|
||||
SupplementaryGroups=video render input audio
|
||||
WorkingDirectory=/opt/buttontask/current
|
||||
Environment=BUTTONTASK_ROOT=/opt/buttontask
|
||||
Environment=BUTTONTASK_CONFIG=/opt/buttontask/config/config.json
|
||||
@@ -15,6 +15,8 @@ Environment=QT_QPA_PLATFORM=eglfs
|
||||
Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
|
||||
Environment=QT_QPA_EGLFS_HIDECURSOR=1
|
||||
Environment=XDG_RUNTIME_DIR=/tmp/runtime-buttontask
|
||||
Environment=JACK_NO_START_SERVER=1
|
||||
Environment=PULSE_RUNTIME_PATH=/tmp/runtime-buttontask/pulse
|
||||
ExecStart=/opt/buttontask/current/ButtonTask --config /opt/buttontask/config/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
+93
-13
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ethernet network configuration via NetworkManager (nmcli).
|
||||
"""Ethernet / Wi-Fi network configuration via NetworkManager (nmcli).
|
||||
|
||||
Reading current state is done directly (read-only nmcli). Applying a new
|
||||
configuration is delegated to the privileged helper script `bt-netconfig`
|
||||
@@ -19,6 +19,7 @@ from paths import scripts_dir
|
||||
network_bp = Blueprint("network", __name__)
|
||||
|
||||
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
|
||||
_SSID_RE = re.compile(r"^[\x20-\x7e]{1,32}$")
|
||||
|
||||
|
||||
def network_config_path():
|
||||
@@ -45,8 +46,8 @@ def _run(args, timeout=10):
|
||||
return 124, "", "timeout"
|
||||
|
||||
|
||||
def list_ethernet_devices():
|
||||
"""Return [{device, state, connection}] for ethernet interfaces."""
|
||||
def list_network_devices():
|
||||
"""Return [{device, type, state, connection}] for ethernet and wifi."""
|
||||
rc, out, _ = _run([
|
||||
"nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status",
|
||||
])
|
||||
@@ -58,21 +59,31 @@ def list_ethernet_devices():
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
device, dtype, state, connection = parts[0], parts[1], parts[2], parts[3]
|
||||
if dtype != "ethernet":
|
||||
if dtype not in ("ethernet", "wifi"):
|
||||
continue
|
||||
devices.append({"device": device, "state": state, "connection": connection})
|
||||
devices.append({
|
||||
"device": device,
|
||||
"type": dtype,
|
||||
"state": state,
|
||||
"connection": connection,
|
||||
})
|
||||
return devices
|
||||
|
||||
|
||||
def list_ethernet_devices():
|
||||
"""Backward-compatible alias — ethernet only. """
|
||||
return [d for d in list_network_devices() if d.get("type") == "ethernet"]
|
||||
|
||||
|
||||
def device_details(device):
|
||||
"""Return current IP4 config for a device."""
|
||||
rc, out, _ = _run([
|
||||
"nmcli", "-t", "-f",
|
||||
"IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.CONNECTION",
|
||||
"IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.CONNECTION,GENERAL.TYPE",
|
||||
"device", "show", device,
|
||||
])
|
||||
info = {"device": device, "addresses": [], "gateway": "", "dns": [],
|
||||
"connection": ""}
|
||||
"connection": "", "type": ""}
|
||||
if rc != 0:
|
||||
return info
|
||||
for line in out.splitlines():
|
||||
@@ -90,14 +101,24 @@ def device_details(device):
|
||||
info["dns"].append(val)
|
||||
elif key == "GENERAL.CONNECTION":
|
||||
info["connection"] = val
|
||||
elif key == "GENERAL.TYPE":
|
||||
# nmcli may report "wifi" / "802-11-wireless" / "ethernet"
|
||||
info["type"] = "wifi" if "wireless" in val or val == "wifi" else (
|
||||
"ethernet" if "ethernet" in val else val
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def current_state():
|
||||
devices = list_ethernet_devices()
|
||||
devices = list_network_devices()
|
||||
for d in devices:
|
||||
d.update({k: v for k, v in device_details(d["device"]).items()
|
||||
if k != "device"})
|
||||
details = device_details(d["device"])
|
||||
for k, v in details.items():
|
||||
if k == "device":
|
||||
continue
|
||||
if k == "type" and d.get("type"):
|
||||
continue
|
||||
d[k] = v
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@@ -116,7 +137,22 @@ def _validate_payload(data):
|
||||
mode = (data.get("mode") or "dhcp").strip().lower()
|
||||
if mode not in ("dhcp", "static"):
|
||||
return None, "mode must be dhcp or static"
|
||||
payload = {"iface": iface, "mode": mode}
|
||||
net_type = (data.get("type") or data.get("netType") or "ethernet").strip().lower()
|
||||
if net_type not in ("ethernet", "wifi"):
|
||||
return None, "type must be ethernet or wifi"
|
||||
|
||||
payload = {"iface": iface, "mode": mode, "type": net_type}
|
||||
|
||||
if net_type == "wifi":
|
||||
ssid = (data.get("ssid") or "").strip()
|
||||
if not ssid or not _SSID_RE.match(ssid):
|
||||
return None, "invalid ssid"
|
||||
payload["ssid"] = ssid
|
||||
password = data.get("password")
|
||||
if password is None:
|
||||
password = data.get("wifiPassword") or ""
|
||||
payload["password"] = str(password)
|
||||
|
||||
if mode == "static":
|
||||
address = (data.get("address") or "").strip()
|
||||
if not _valid_ip(address):
|
||||
@@ -147,7 +183,15 @@ def _validate_payload(data):
|
||||
@network_bp.route("/api/network", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_network():
|
||||
return jsonify(current_state())
|
||||
state = current_state()
|
||||
cfg_path = network_config_path()
|
||||
if cfg_path.is_file():
|
||||
try:
|
||||
with cfg_path.open(encoding="utf-8") as f:
|
||||
state["saved"] = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return jsonify(state)
|
||||
|
||||
|
||||
@network_bp.route("/api/network", methods=["PUT"])
|
||||
@@ -163,10 +207,46 @@ def api_set_network():
|
||||
if payload["mode"] == "static":
|
||||
args += [payload["address"], str(payload["prefix"]),
|
||||
payload.get("gateway", ""), payload.get("dns", "")]
|
||||
if payload.get("type") == "wifi":
|
||||
args += ["wifi", payload["ssid"], payload.get("password", "")]
|
||||
|
||||
rc, out, err_out = _run(args, timeout=60)
|
||||
if rc != 0:
|
||||
return jsonify({"ok": False, "error": err_out.strip() or out.strip()
|
||||
or f"exit {rc}"}), 500
|
||||
|
||||
# Persist without storing empty wifi password overwrite if omitted? store as given.
|
||||
save_payload = {k: v for k, v in payload.items()}
|
||||
save_payload.pop("disabled", None)
|
||||
save_network_config(save_payload)
|
||||
return jsonify({"ok": True, "output": out.strip()})
|
||||
|
||||
|
||||
@network_bp.route("/api/network/disconnect", methods=["POST"])
|
||||
@login_required
|
||||
def api_disconnect_network():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
iface = (data.get("iface") or "").strip()
|
||||
if not _IFACE_RE.match(iface):
|
||||
abort(400, "invalid iface")
|
||||
|
||||
script = str(scripts_dir() / "bt-netconfig")
|
||||
args = ["sudo", "-n", script, iface, "disconnect"]
|
||||
rc, out, err_out = _run(args, timeout=30)
|
||||
if rc != 0:
|
||||
return jsonify({"ok": False, "error": err_out.strip() or out.strip()
|
||||
or f"exit {rc}"}), 500
|
||||
save_network_config(payload)
|
||||
|
||||
# If saved config pointed at this iface, clear it so boot won't re-apply.
|
||||
cfg_path = network_config_path()
|
||||
if cfg_path.is_file():
|
||||
try:
|
||||
with cfg_path.open(encoding="utf-8") as f:
|
||||
saved = json.load(f)
|
||||
if (saved.get("iface") or "").strip() == iface:
|
||||
saved["disabled"] = True
|
||||
save_network_config(saved)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return jsonify({"ok": True, "output": out.strip()})
|
||||
|
||||
+126
-25
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# bt-netconfig - apply Ethernet configuration via NetworkManager.
|
||||
# bt-netconfig - apply Ethernet or Wi-Fi configuration via NetworkManager.
|
||||
#
|
||||
# Usage:
|
||||
# bt-netconfig <iface> dhcp
|
||||
# bt-netconfig <iface> static <address> <prefix> [gateway] [dns_csv]
|
||||
# bt-netconfig <iface> dhcp wifi <ssid> [password]
|
||||
# bt-netconfig <iface> static <address> <prefix> [gateway] [dns_csv] wifi <ssid> [password]
|
||||
#
|
||||
# Intended to be invoked through sudo by the (unprivileged) web configurator.
|
||||
# Do NOT use ip/ifconfig directly — NetworkManager will undo manual changes.
|
||||
@@ -16,35 +18,122 @@ set -euo pipefail
|
||||
|
||||
err() { echo "bt-netconfig: $*" >&2; exit 1; }
|
||||
|
||||
[ "$#" -ge 2 ] || err "usage: bt-netconfig <iface> <dhcp|static> ..."
|
||||
[ "$#" -ge 2 ] || err "usage: bt-netconfig <iface> <dhcp|static|disconnect> [args...] [wifi <ssid> [password]]"
|
||||
|
||||
IFACE="$1"
|
||||
MODE="$2"
|
||||
shift 2
|
||||
PROFILE="buttontask-$IFACE"
|
||||
BOOT="${BUTTONTASK_NET_BOOT:-0}"
|
||||
ACTIVATE_TIMEOUT="${BUTTONTASK_NET_ACTIVATE_TIMEOUT:-30}"
|
||||
NET_TYPE="ethernet"
|
||||
SSID=""
|
||||
WIFI_PASS=""
|
||||
|
||||
[[ "$IFACE" =~ ^[A-Za-z0-9_.:-]{1,32}$ ]] || err "invalid interface name"
|
||||
command -v nmcli >/dev/null 2>&1 || err "nmcli not found"
|
||||
|
||||
# Ensure NM manages this interface (manual ip addr fights with NM).
|
||||
# Fast path: disconnect / disable interface without reconfiguring.
|
||||
if [ "$MODE" = "disconnect" ]; then
|
||||
nmcli device disconnect "$IFACE" 2>/dev/null || true
|
||||
# Turn off autoconnect on our profile so it does not come back immediately.
|
||||
if nmcli -t -f NAME connection show | grep -Fxq "$PROFILE"; then
|
||||
nmcli connection modify "$PROFILE" connection.autoconnect no 2>/dev/null || true
|
||||
nmcli connection down "$PROFILE" 2>/dev/null || true
|
||||
fi
|
||||
# Also disable autoconnect on other profiles bound to this iface.
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
bound="$(nmcli -g connection.interface-name connection show "$name" 2>/dev/null || true)"
|
||||
if [ "$bound" = "$IFACE" ] || [ "$name" = "$PROFILE" ]; then
|
||||
nmcli connection modify "$name" connection.autoconnect no 2>/dev/null || true
|
||||
fi
|
||||
done < <(nmcli -t -f NAME connection show 2>/dev/null || true)
|
||||
echo "disconnected $IFACE (autoconnect off)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ "$#" -ge 0 ] || true
|
||||
# Parse remaining args: optional static fields, then optional "wifi ssid [password]"
|
||||
ADDR=""
|
||||
PREFIX=""
|
||||
GW=""
|
||||
DNS=""
|
||||
ARGS=("$@")
|
||||
i=0
|
||||
n=${#ARGS[@]}
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
if [ "${ARGS[$i]}" = "wifi" ]; then
|
||||
NET_TYPE="wifi"
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "$n" ] || err "wifi requires <ssid>"
|
||||
SSID="${ARGS[$i]}"
|
||||
i=$((i + 1))
|
||||
if [ "$i" -lt "$n" ]; then
|
||||
WIFI_PASS="${ARGS[$i]}"
|
||||
i=$((i + 1))
|
||||
fi
|
||||
break
|
||||
fi
|
||||
case "$MODE" in
|
||||
static)
|
||||
if [ -z "$ADDR" ]; then ADDR="${ARGS[$i]}"
|
||||
elif [ -z "$PREFIX" ]; then PREFIX="${ARGS[$i]}"
|
||||
elif [ -z "$GW" ]; then GW="${ARGS[$i]}"
|
||||
elif [ -z "$DNS" ]; then DNS="${ARGS[$i]}"
|
||||
else err "unexpected argument: ${ARGS[$i]}"
|
||||
fi
|
||||
;;
|
||||
dhcp)
|
||||
err "unexpected argument for dhcp: ${ARGS[$i]}"
|
||||
;;
|
||||
*)
|
||||
err "mode must be dhcp, static or disconnect"
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
if [ "$NET_TYPE" = "wifi" ]; then
|
||||
[ -n "$SSID" ] || err "wifi requires <ssid>"
|
||||
fi
|
||||
|
||||
# Ensure NM manages this interface.
|
||||
nmcli device set "$IFACE" managed yes 2>/dev/null || true
|
||||
|
||||
# Use a dedicated profile per interface so we never fight "Wired connection 1".
|
||||
if ! nmcli -t -f NAME connection show | grep -Fxq "$PROFILE"; then
|
||||
nmcli connection add type ethernet ifname "$IFACE" con-name "$PROFILE" \
|
||||
connection.autoconnect yes >/dev/null
|
||||
fi
|
||||
# Create dedicated profile of the right type.
|
||||
ensure_profile() {
|
||||
if nmcli -t -f NAME connection show | grep -Fxq "$PROFILE"; then
|
||||
local typ
|
||||
typ="$(nmcli -g connection.type connection show "$PROFILE" 2>/dev/null || true)"
|
||||
if [ "$NET_TYPE" = "wifi" ] && [ "$typ" != "802-11-wireless" ]; then
|
||||
nmcli connection delete "$PROFILE" >/dev/null 2>&1 || true
|
||||
elif [ "$NET_TYPE" = "ethernet" ] && [ "$typ" != "802-3-ethernet" ]; then
|
||||
nmcli connection delete "$PROFILE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
if ! nmcli -t -f NAME connection show | grep -Fxq "$PROFILE"; then
|
||||
if [ "$NET_TYPE" = "wifi" ]; then
|
||||
nmcli connection add type wifi ifname "$IFACE" con-name "$PROFILE" \
|
||||
ssid "$SSID" connection.autoconnect yes >/dev/null
|
||||
else
|
||||
nmcli connection add type ethernet ifname "$IFACE" con-name "$PROFILE" \
|
||||
connection.autoconnect yes >/dev/null
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_profile
|
||||
CON="$PROFILE"
|
||||
|
||||
# Disable autoconnect on other ethernet profiles for this interface.
|
||||
# Disable autoconnect on other profiles for this interface (same media type).
|
||||
disable_other_profiles() {
|
||||
local iface="$1" con="$2" name bound typ
|
||||
local iface="$1" con="$2" want_type="$3" name bound typ
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
[ "$name" = "$con" ] && continue
|
||||
typ="$(nmcli -g connection.type connection show "$name" 2>/dev/null || true)"
|
||||
[ "$typ" = "802-3-ethernet" ] || continue
|
||||
[ "$typ" = "$want_type" ] || continue
|
||||
bound="$(nmcli -g connection.interface-name connection show "$name" 2>/dev/null || true)"
|
||||
if [ -n "$bound" ] && [ "$bound" != "--" ] && [ "$bound" != "$iface" ]; then
|
||||
continue
|
||||
@@ -53,12 +142,30 @@ disable_other_profiles() {
|
||||
done < <(nmcli -t -f NAME connection show 2>/dev/null || true)
|
||||
}
|
||||
|
||||
disable_other_profiles "$IFACE" "$CON"
|
||||
|
||||
nmcli connection modify "$CON" \
|
||||
connection.interface-name "$IFACE" \
|
||||
connection.autoconnect yes \
|
||||
connection.autoconnect-priority 100
|
||||
if [ "$NET_TYPE" = "wifi" ]; then
|
||||
disable_other_profiles "$IFACE" "$CON" "802-11-wireless"
|
||||
nmcli connection modify "$CON" \
|
||||
connection.interface-name "$IFACE" \
|
||||
connection.autoconnect yes \
|
||||
connection.autoconnect-priority 100 \
|
||||
wifi.ssid "$SSID"
|
||||
if [ -n "$WIFI_PASS" ]; then
|
||||
nmcli connection modify "$CON" \
|
||||
wifi-sec.key-mgmt wpa-psk \
|
||||
wifi-sec.psk "$WIFI_PASS"
|
||||
else
|
||||
# Open network
|
||||
nmcli connection modify "$CON" \
|
||||
wifi-sec.key-mgmt none 2>/dev/null || true
|
||||
nmcli connection modify "$CON" remove wifi-sec.psk 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
disable_other_profiles "$IFACE" "$CON" "802-3-ethernet"
|
||||
nmcli connection modify "$CON" \
|
||||
connection.interface-name "$IFACE" \
|
||||
connection.autoconnect yes \
|
||||
connection.autoconnect-priority 100
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
dhcp)
|
||||
@@ -71,11 +178,7 @@ case "$MODE" in
|
||||
ipv4.never-default no
|
||||
;;
|
||||
static)
|
||||
[ "$#" -ge 4 ] || err "static requires <address> <prefix>"
|
||||
ADDR="$3"
|
||||
PREFIX="$4"
|
||||
GW="${5:-}"
|
||||
DNS="${6:-}"
|
||||
[ -n "$ADDR" ] && [ -n "$PREFIX" ] || err "static requires <address> <prefix>"
|
||||
[[ "$PREFIX" =~ ^[0-9]{1,2}$ ]] && [ "$PREFIX" -ge 1 ] && [ "$PREFIX" -le 32 ] \
|
||||
|| err "invalid prefix"
|
||||
if [ -n "$GW" ]; then
|
||||
@@ -101,7 +204,6 @@ case "$MODE" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Drop the active profile so the new settings apply cleanly.
|
||||
nmcli device disconnect "$IFACE" 2>/dev/null || true
|
||||
nmcli connection down "$CON" 2>/dev/null || true
|
||||
|
||||
@@ -126,8 +228,7 @@ if ! activate_connection "$CON" "$IFACE"; then
|
||||
err "connection activation failed or timed out after ${ACTIVATE_TIMEOUT}s"
|
||||
fi
|
||||
|
||||
# Brief settle, then report what NM actually applied.
|
||||
sleep 1
|
||||
APPLIED="$(nmcli -t -f IP4.ADDRESS device show "$IFACE" 2>/dev/null \
|
||||
| sed 's/^IP4.ADDRESS://' | head -n1 | tr '\n' ' ')"
|
||||
echo "applied $MODE on $IFACE ($CON)${APPLIED:+ ip=$APPLIED}"
|
||||
echo "applied $MODE on $IFACE ($CON type=$NET_TYPE)${SSID:+ ssid=$SSID}${APPLIED:+ ip=$APPLIED}"
|
||||
|
||||
@@ -56,6 +56,9 @@ import json, os, subprocess, sys
|
||||
cfg_path, script = sys.argv[1], sys.argv[2]
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
if cfg.get("disabled"):
|
||||
print("bt-netconfig-boot: saved config marked disabled, skip", flush=True)
|
||||
sys.exit(0)
|
||||
iface = (cfg.get("iface") or "").strip()
|
||||
mode = (cfg.get("mode") or "").strip()
|
||||
if not iface or mode not in ("dhcp", "static"):
|
||||
@@ -68,6 +71,12 @@ if mode == "static":
|
||||
cfg.get("gateway", ""),
|
||||
cfg.get("dns", ""),
|
||||
]
|
||||
net_type = (cfg.get("type") or "ethernet").strip().lower()
|
||||
if net_type == "wifi":
|
||||
ssid = (cfg.get("ssid") or "").strip()
|
||||
if not ssid:
|
||||
sys.exit(0)
|
||||
args += ["wifi", ssid, cfg.get("password") or ""]
|
||||
env = os.environ.copy()
|
||||
env.setdefault("BUTTONTASK_NET_BOOT", "1")
|
||||
raise SystemExit(subprocess.run(args, env=env).returncode)
|
||||
|
||||
+118
-15
@@ -312,28 +312,63 @@ $("#b-rc-add-rule")?.addEventListener("click", () => {
|
||||
|
||||
function syncHoldRow() {
|
||||
const mode = $("#b-trigger-mode").value;
|
||||
$("#b-hold-row").style.display = mode === "click" ? "none" : "";
|
||||
const isVoice = $("#b-kind")?.value === "voice";
|
||||
$("#b-hold-row").style.display = (mode === "click" || isVoice) ? "none" : "";
|
||||
$("#b-hold-val").textContent = $("#b-hold").value;
|
||||
}
|
||||
|
||||
function syncKindUi() {
|
||||
const isVoice = $("#b-kind")?.value === "voice";
|
||||
const voiceBlock = $("#b-voice-block");
|
||||
const latchBlock = $("#b-latch-block");
|
||||
const rcBlock = $("#b-response-check-block");
|
||||
const typeRow = $("#b-type-row");
|
||||
if (voiceBlock) voiceBlock.style.display = isVoice ? "" : "none";
|
||||
if (latchBlock) latchBlock.style.display = isVoice ? "none" : "";
|
||||
if (rcBlock) rcBlock.style.display = isVoice ? "none" : "";
|
||||
if (typeRow) typeRow.style.display = isVoice ? "none" : "";
|
||||
const triggerLabel = $("#b-trigger-label");
|
||||
if (triggerLabel) {
|
||||
const sel = $("#b-trigger-mode");
|
||||
if (sel && isVoice) {
|
||||
sel.options[0].text = "Удержание + речь";
|
||||
sel.options[1].text = "Клик старт/стоп";
|
||||
} else if (sel) {
|
||||
sel.options[0].text = "Удержание";
|
||||
sel.options[1].text = "Клик";
|
||||
}
|
||||
}
|
||||
if (isVoice && $("#b-action-timeout") && Number($("#b-action-timeout").value) === 7000) {
|
||||
$("#b-action-timeout").value = 60000;
|
||||
}
|
||||
syncHoldRow();
|
||||
syncLatchUi();
|
||||
}
|
||||
|
||||
$("#b-kind")?.addEventListener("change", syncKindUi);
|
||||
|
||||
function openDialogFor(btn) {
|
||||
$("#btn-dialog-title").textContent = btn ? "Редактирование" : "Новая кнопка";
|
||||
$("#b-id").value = btn?.id || "";
|
||||
$("#b-label").value = btn?.label || "";
|
||||
$("#b-kind").value = btn?.kind === "voice" ? "voice" : "press";
|
||||
$("#b-icon").value = btn?.iconPath || "";
|
||||
$("#b-type").value = btn?.action?.type || "http_get";
|
||||
$("#b-url").value = btn?.action?.url || "";
|
||||
$("#b-action-timeout").value = btn?.action?.timeoutMs ?? 7000;
|
||||
$("#b-action-timeout").value = btn?.action?.timeoutMs ?? (btn?.kind === "voice" ? 60000 : 7000);
|
||||
$("#b-success").value = btn?.feedback?.successText || "";
|
||||
$("#b-error").value = btn?.feedback?.errorText || "Ошибка";
|
||||
$("#b-pending").value = btn?.feedback?.pendingText ?? "...";
|
||||
$("#b-pending").value = btn?.feedback?.pendingText ?? (btn?.kind === "voice" ? "Слушаю..." : "...");
|
||||
$("#b-color").value = btn?.color || "#7b007b";
|
||||
$("#b-trigger-mode").value = btn?.trigger?.mode || "hold";
|
||||
$("#b-hold").value = btn?.trigger?.holdMs || 800;
|
||||
$("#b-cancel-url").value = btn?.voice?.cancelUrl || "";
|
||||
$("#b-source-id").value = btn?.voice?.sourceId || "panel-01";
|
||||
$("#b-api-key").value = btn?.action?.headers?.["X-API-Key"] || "";
|
||||
$("#b-max-record").value = btn?.voice?.maxRecordMs || 30000;
|
||||
fillLatchResetForm(btn?.latchReset);
|
||||
fillResponseCheckForm(btn?.action?.responseCheck);
|
||||
syncHoldRow();
|
||||
syncLatchUi();
|
||||
syncKindUi();
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
@@ -364,22 +399,38 @@ form?.addEventListener("submit", async (e) => {
|
||||
if (e.submitter && e.submitter.value === "cancel") return;
|
||||
e.preventDefault();
|
||||
const id = $("#b-id").value;
|
||||
const latchReset = readLatchResetFromForm();
|
||||
const kind = $("#b-kind")?.value || "press";
|
||||
const latchReset = kind === "voice"
|
||||
? { enabled: false }
|
||||
: readLatchResetFromForm();
|
||||
const payload = {
|
||||
label: $("#b-label").value,
|
||||
iconPath: $("#b-icon").value,
|
||||
actionType: $("#b-type").value,
|
||||
kind,
|
||||
actionType: kind === "voice" ? "http_post" : $("#b-type").value,
|
||||
url: $("#b-url").value,
|
||||
timeoutMs: parseInt($("#b-action-timeout").value, 10) || 7000,
|
||||
timeoutMs: parseInt($("#b-action-timeout").value, 10) || (kind === "voice" ? 60000 : 7000),
|
||||
successText: $("#b-success").value,
|
||||
errorText: $("#b-error").value,
|
||||
pendingText: $("#b-pending").value || "...",
|
||||
pendingText: $("#b-pending").value || (kind === "voice" ? "Слушаю..." : "..."),
|
||||
color: $("#b-color").value,
|
||||
triggerMode: $("#b-trigger-mode").value,
|
||||
holdMs: parseInt($("#b-hold").value, 10),
|
||||
latchReset,
|
||||
responseCheck: latchReset.enabled ? { enabled: false } : readResponseCheckFromForm(),
|
||||
responseCheck: (kind === "voice" || latchReset.enabled)
|
||||
? { enabled: false }
|
||||
: readResponseCheckFromForm(),
|
||||
};
|
||||
if (kind === "voice") {
|
||||
payload.voice = {
|
||||
cancelUrl: ($("#b-cancel-url")?.value || "").trim(),
|
||||
sourceId: ($("#b-source-id")?.value || "").trim() || "panel-01",
|
||||
maxRecordMs: parseInt($("#b-max-record")?.value, 10) || 30000,
|
||||
};
|
||||
const apiKey = ($("#b-api-key")?.value || "").trim();
|
||||
payload.headers = apiKey ? { "X-API-Key": apiKey } : {};
|
||||
payload.apiKey = apiKey;
|
||||
}
|
||||
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
|
||||
else await api("POST", "/api/buttons", payload);
|
||||
dlg.close();
|
||||
@@ -394,6 +445,24 @@ function toggleStatic() {
|
||||
}
|
||||
$("#net-mode")?.addEventListener("change", toggleStatic);
|
||||
|
||||
function deviceTypeMap(devs) {
|
||||
const m = {};
|
||||
(devs || []).forEach(d => { m[d.device] = d.type || "ethernet"; });
|
||||
return m;
|
||||
}
|
||||
|
||||
let _netDeviceTypes = {};
|
||||
|
||||
function toggleWifiFields() {
|
||||
const wrap = $("#net-mode")?.closest(".grid-form");
|
||||
if (!wrap) return;
|
||||
const iface = $("#net-iface")?.value;
|
||||
const isWifi = (_netDeviceTypes[iface] || "ethernet") === "wifi";
|
||||
wrap.classList.toggle("show-wifi", isWifi);
|
||||
}
|
||||
|
||||
$("#net-iface")?.addEventListener("change", toggleWifiFields);
|
||||
|
||||
async function loadNetwork() {
|
||||
const statusEl = $("#net-status");
|
||||
const ifaceSel = $("#net-iface");
|
||||
@@ -402,25 +471,52 @@ async function loadNetwork() {
|
||||
try {
|
||||
const data = await api("GET", "/api/network");
|
||||
const devs = data.devices || [];
|
||||
_netDeviceTypes = deviceTypeMap(devs);
|
||||
if (!devs.length) {
|
||||
statusEl.innerHTML = "<p class='muted'>Ethernet-интерфейсы не найдены (или nmcli недоступен).</p>";
|
||||
statusEl.innerHTML = "<p class='muted'>Сетевые интерфейсы не найдены (или nmcli недоступен).</p>";
|
||||
} else {
|
||||
statusEl.innerHTML = devs.map(d => {
|
||||
const up = (d.state || "").includes("connected") && !(d.state || "").includes("dis");
|
||||
const badge = up ? "<span class='badge up'>up</span>" : "<span class='badge down'>down</span>";
|
||||
const addr = (d.addresses || []).join(", ") || "—";
|
||||
const typ = d.type || "ethernet";
|
||||
return `<div class="net-device">
|
||||
<div class="name">${d.device} ${badge}</div>
|
||||
<div class="name">${d.device} <span class="muted">[${typ}]</span> ${badge}</div>
|
||||
<div class="detail">Соединение: ${d.connection || "—"}</div>
|
||||
<div class="detail">IP: ${addr}</div>
|
||||
<div class="detail">Шлюз: ${d.gateway || "—"} · DNS: ${(d.dns||[]).join(", ") || "—"}</div>
|
||||
<button type="button" class="ghost net-disconnect" data-iface="${d.device}">Отключить</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
statusEl.querySelectorAll(".net-disconnect").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const iface = btn.dataset.iface;
|
||||
if (!confirm(`Отключить ${iface}?`)) return;
|
||||
try {
|
||||
await api("POST", "/api/network/disconnect", { iface });
|
||||
flash(`Интерфейс ${iface} отключён`);
|
||||
setTimeout(loadNetwork, 1000);
|
||||
} catch (e) { flash(e.message, true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
if (ifaceSel) {
|
||||
const prev = ifaceSel.value;
|
||||
ifaceSel.innerHTML = devs.map(d => `<option value="${d.device}">${d.device}</option>`).join("");
|
||||
if (prev) ifaceSel.value = prev;
|
||||
const saved = data.saved || {};
|
||||
ifaceSel.innerHTML = devs.map(d =>
|
||||
`<option value="${d.device}">${d.device} (${d.type || "?"})</option>`
|
||||
).join("");
|
||||
if (saved.iface) ifaceSel.value = saved.iface;
|
||||
else if (prev) ifaceSel.value = prev;
|
||||
if (saved.ssid) $("#net-ssid").value = saved.ssid;
|
||||
if (saved.password != null) $("#net-password").value = saved.password;
|
||||
if (saved.mode) $("#net-mode").value = saved.mode;
|
||||
if (saved.address) $("#net-address").value = saved.address;
|
||||
if (saved.prefix) $("#net-prefix").value = saved.prefix;
|
||||
if (saved.gateway) $("#net-gateway").value = saved.gateway;
|
||||
if (saved.dns) $("#net-dns").value = saved.dns;
|
||||
toggleStatic();
|
||||
toggleWifiFields();
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.innerHTML = `<p class='warn'>${e.message}</p>`;
|
||||
@@ -431,7 +527,14 @@ $("#net-refresh")?.addEventListener("click", loadNetwork);
|
||||
|
||||
$("#net-apply")?.addEventListener("click", async () => {
|
||||
const mode = $("#net-mode").value;
|
||||
const payload = { iface: $("#net-iface").value, mode };
|
||||
const iface = $("#net-iface").value;
|
||||
const netType = _netDeviceTypes[iface] || "ethernet";
|
||||
const payload = { iface, mode, type: netType };
|
||||
if (netType === "wifi") {
|
||||
payload.ssid = ($("#net-ssid")?.value || "").trim();
|
||||
payload.password = $("#net-password")?.value || "";
|
||||
if (!payload.ssid) { flash("Укажите SSID Wi‑Fi", true); return; }
|
||||
}
|
||||
if (mode === "static") {
|
||||
payload.address = $("#net-address").value.trim();
|
||||
payload.prefix = parseInt($("#net-prefix").value, 10);
|
||||
|
||||
@@ -275,6 +275,8 @@ button.icon-only { padding: 6px 9px; line-height: 1; }
|
||||
.badge.down { background: rgba(185,28,28,0.2); color: #f87171; }
|
||||
.static-only { display: none; }
|
||||
.grid-form.show-static .static-only { display: flex; }
|
||||
.wifi-only { display: none; }
|
||||
.grid-form.show-wifi .wifi-only { display: flex; }
|
||||
|
||||
/* ---------- Update ---------- */
|
||||
.update-layout {
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<section class="panel" id="tab-network">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Ethernet</h2>
|
||||
<h2>Сеть (Ethernet / Wi‑Fi)</h2>
|
||||
<button id="net-refresh" class="ghost">↻ Обновить</button>
|
||||
</div>
|
||||
<div id="net-status" class="net-status">Загрузка состояния…</div>
|
||||
@@ -253,6 +253,12 @@
|
||||
<option value="static">Статический IP</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="wifi-only">SSID Wi‑Fi
|
||||
<input type="text" id="net-ssid" placeholder="Имя сети" maxlength="32">
|
||||
</label>
|
||||
<label class="wifi-only">Пароль Wi‑Fi
|
||||
<input type="password" id="net-password" placeholder="Пустой = открытая сеть">
|
||||
</label>
|
||||
<label class="static-only">IP-адрес <input type="text" id="net-address" placeholder="192.168.1.50"></label>
|
||||
<label class="static-only">Маска (префикс) <input type="number" id="net-prefix" min="1" max="32" value="24"></label>
|
||||
<label class="static-only">Шлюз <input type="text" id="net-gateway" placeholder="192.168.1.1"></label>
|
||||
@@ -425,6 +431,12 @@
|
||||
<h3 id="btn-dialog-title">Кнопка</h3>
|
||||
<input type="hidden" id="b-id">
|
||||
<label>Подпись <input type="text" id="b-label" required></label>
|
||||
<label>Тип кнопки
|
||||
<select id="b-kind">
|
||||
<option value="press">Нажатие</option>
|
||||
<option value="voice">Голос (запись)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Иконка
|
||||
<select id="b-icon">
|
||||
<option value="">— нет —</option>
|
||||
@@ -433,17 +445,27 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Метод
|
||||
<label id="b-type-row">Метод
|
||||
<select id="b-type">
|
||||
<option value="http_get">http_get</option>
|
||||
<option value="http_post">http_post</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>URL <input type="text" id="b-url" required></label>
|
||||
<label>URL <input type="text" id="b-url" required placeholder="http://host/api/..."></label>
|
||||
<label>Ожидание ответа, мс
|
||||
<input type="number" id="b-action-timeout" min="1000" max="600000" step="500" value="7000">
|
||||
</label>
|
||||
<p class="hint b-action-timeout-hint">Таймаут одного HTTP-запроса. По умолчанию 7000 мс (7 с). При «Мгновенном отклике» влияет только на фоновый запрос и лог.</p>
|
||||
<p class="hint b-action-timeout-hint">Таймаут одного HTTP-запроса. Для голоса обычно 60000.</p>
|
||||
|
||||
<div class="voice-block" id="b-voice-block" style="display:none">
|
||||
<label>URL отмены (cancel) <input type="text" id="b-cancel-url" placeholder="http://host/api/v1/cancel"></label>
|
||||
<label>source_id <input type="text" id="b-source-id" placeholder="panel-01"></label>
|
||||
<label>X-API-Key <input type="password" id="b-api-key" placeholder="ключ API сервера" autocomplete="off"></label>
|
||||
<label>Макс. запись, мс
|
||||
<input type="number" id="b-max-record" min="1000" max="120000" step="1000" value="30000">
|
||||
</label>
|
||||
<p class="hint">Удержание: запись пока зажата. Клик: старт/стоп. Повторное нажатие на горящей кнопке — отмена на cancel URL. Ключ уходит в заголовке X-API-Key.</p>
|
||||
</div>
|
||||
|
||||
<div class="latch-reset-block" id="b-latch-block">
|
||||
<label class="check">
|
||||
@@ -466,7 +488,7 @@
|
||||
<label>Текст ошибки <input type="text" id="b-error" value="Ошибка"></label>
|
||||
<label>Текст ожидания <input type="text" id="b-pending" value="..."></label>
|
||||
<label>Цвет кнопки <input type="color" id="b-color" value="#7b007b"></label>
|
||||
<label>Срабатывание
|
||||
<label id="b-trigger-label">Срабатывание
|
||||
<select id="b-trigger-mode">
|
||||
<option value="hold">Удержание</option>
|
||||
<option value="click">Клик</option>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -42,7 +42,7 @@ ls /usr/lib/*/qt5/qml/QtQuick/Controls.2 >/dev/null 2>&1 \
|
||||
# ---------------------------------------------------------------------------
|
||||
id "$USER_NAME" >/dev/null 2>&1 || useradd -r -s /bin/false "$USER_NAME"
|
||||
# группы для eglfs/KMS/тача
|
||||
usermod -aG video,render,input "$USER_NAME" 2>/dev/null || true
|
||||
usermod -aG video,render,input,audio "$USER_NAME" 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Версионированная раскладка
|
||||
|
||||
@@ -14,6 +14,10 @@ libqt5quickcontrols2-5
|
||||
libqt5quicktemplates2-5
|
||||
libqt5quickshapes5
|
||||
|
||||
# --- Qt5 Multimedia (голосовая запись) ---
|
||||
libqt5multimedia5
|
||||
libqt5multimedia5-plugins
|
||||
|
||||
# --- QML-модули (см. CMake deps в README) ---
|
||||
qml-module-qtquick2
|
||||
qml-module-qtquick-controls2
|
||||
|
||||
Binary file not shown.
@@ -36,17 +36,22 @@
|
||||
{
|
||||
"id": "cleaning-call",
|
||||
"label": "Вызов клининга",
|
||||
"kind": "press",
|
||||
"iconPath": "cleaner1Crop.png",
|
||||
"color": "#000000",
|
||||
"action": {
|
||||
"type": "http_get",
|
||||
"url": "http://192.168.30.147:80/api/click/btn?bid=01*04*01",
|
||||
"url": "https://button.grigowashere.ru/api/click/btn?bid=01*04*01",
|
||||
"headers": {},
|
||||
"body": "",
|
||||
"timeoutMs": 15000
|
||||
"timeoutMs": 15000,
|
||||
"responseCheck": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"latchReset": {
|
||||
"enabled": true,
|
||||
"resetUrl": "http://192.168.30.147:80/api/click/btn?bid=01*04*00",
|
||||
"resetUrl": "https://button.grigowashere.ru/api/click/btn?bid=01*04*00",
|
||||
"successMatch": "OK",
|
||||
"fireAndForget": true
|
||||
},
|
||||
@@ -59,8 +64,44 @@
|
||||
"trigger": {
|
||||
"mode": "hold",
|
||||
"holdMs": 800
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "voice-call",
|
||||
"label": "Аудио",
|
||||
"kind": "voice",
|
||||
"iconPath": "cleaner1Crop.png",
|
||||
"color": "#2a6f97",
|
||||
"action": {
|
||||
"type": "http_post",
|
||||
"url": "https://button.grigowashere.ru/api/v1/classify-audio",
|
||||
"headers": {
|
||||
"X-API-Key": "97098109-3188-496b-b075-0e8d83c2bef5"
|
||||
},
|
||||
"body": "",
|
||||
"timeoutMs": 60000,
|
||||
"responseCheck": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"color": "#000000"
|
||||
"voice": {
|
||||
"sourceId": "panel-01",
|
||||
"cancelUrl": "https://button.grigowashere.ru/api/v1/cancel",
|
||||
"maxRecordMs": 30000
|
||||
},
|
||||
"latchReset": {
|
||||
"enabled": false
|
||||
},
|
||||
"feedback": {
|
||||
"successText": "",
|
||||
"errorText": "Ошибка",
|
||||
"pendingText": "Слушаю...",
|
||||
"fadeMs": 5000
|
||||
},
|
||||
"trigger": {
|
||||
"mode": "hold",
|
||||
"holdMs": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -116,11 +116,55 @@ def _apply_button_latch_reset(btn, data):
|
||||
return btn
|
||||
|
||||
|
||||
def _apply_button_voice(btn, data):
|
||||
"""Apply kind + voice block. Voice buttons disable latch."""
|
||||
kind = (data.get("kind") or btn.get("kind") or "press").strip().lower()
|
||||
if kind not in ("press", "voice"):
|
||||
abort(400, "kind must be press or voice")
|
||||
btn["kind"] = kind
|
||||
if kind != "voice":
|
||||
btn.pop("voice", None)
|
||||
return btn
|
||||
|
||||
voice_in = data.get("voice") if isinstance(data.get("voice"), dict) else {}
|
||||
cancel_url = (voice_in.get("cancelUrl") or data.get("cancelUrl") or "").strip()
|
||||
source_id = (voice_in.get("sourceId") or data.get("sourceId") or "").strip()
|
||||
try:
|
||||
max_record_ms = int(voice_in.get("maxRecordMs", data.get("maxRecordMs", 30000)))
|
||||
except (TypeError, ValueError):
|
||||
abort(400, "maxRecordMs must be an integer")
|
||||
if max_record_ms < 1000 or max_record_ms > 120000:
|
||||
abort(400, "maxRecordMs must be 1000..120000")
|
||||
|
||||
btn["voice"] = {
|
||||
"cancelUrl": cancel_url,
|
||||
"sourceId": source_id or btn.get("id") or "panel-01",
|
||||
"maxRecordMs": max_record_ms,
|
||||
}
|
||||
# Voice uses its own cancel flow — disable latch.
|
||||
btn["latchReset"] = {"enabled": False}
|
||||
action = btn.setdefault("action", {})
|
||||
action["type"] = "http_post"
|
||||
if "timeoutMs" not in action:
|
||||
action["timeoutMs"] = 60000
|
||||
return btn
|
||||
|
||||
|
||||
def _apply_button_action(action, data):
|
||||
action["type"] = data.get("actionType", action.get("type", "http_get"))
|
||||
action["url"] = data.get("url", action.get("url", ""))
|
||||
if "headers" in data:
|
||||
action["headers"] = data["headers"]
|
||||
action["headers"] = data["headers"] if isinstance(data["headers"], dict) else {}
|
||||
if "apiKey" in data:
|
||||
headers = action.setdefault("headers", {})
|
||||
if not isinstance(headers, dict):
|
||||
headers = {}
|
||||
action["headers"] = headers
|
||||
key = (data.get("apiKey") or "").strip()
|
||||
if key:
|
||||
headers["X-API-Key"] = key
|
||||
else:
|
||||
headers.pop("X-API-Key", None)
|
||||
if "body" in data:
|
||||
action["body"] = data["body"]
|
||||
if "timeoutMs" in data:
|
||||
@@ -195,6 +239,7 @@ def api_add_button():
|
||||
"color": data.get("color", "#7b007b"),
|
||||
}
|
||||
_apply_button_latch_reset(btn, data)
|
||||
_apply_button_voice(btn, data)
|
||||
cfg.setdefault("buttons", []).append(btn)
|
||||
save_config(cfg)
|
||||
return jsonify(btn)
|
||||
@@ -228,6 +273,7 @@ def api_update_button(bid):
|
||||
if "color" in data:
|
||||
b["color"] = data["color"]
|
||||
_apply_button_latch_reset(b, data)
|
||||
_apply_button_voice(b, data)
|
||||
save_config(cfg)
|
||||
return jsonify(b)
|
||||
abort(404)
|
||||
|
||||
@@ -7,7 +7,7 @@ Wants=NetworkManager.service
|
||||
Type=simple
|
||||
User=buttontask
|
||||
Group=buttontask
|
||||
SupplementaryGroups=video render input
|
||||
SupplementaryGroups=video render input audio
|
||||
WorkingDirectory=/opt/buttontask/current
|
||||
Environment=BUTTONTASK_ROOT=/opt/buttontask
|
||||
Environment=BUTTONTASK_CONFIG=/opt/buttontask/config/config.json
|
||||
@@ -15,6 +15,8 @@ Environment=QT_QPA_PLATFORM=eglfs
|
||||
Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
|
||||
Environment=QT_QPA_EGLFS_HIDECURSOR=1
|
||||
Environment=XDG_RUNTIME_DIR=/tmp/runtime-buttontask
|
||||
Environment=JACK_NO_START_SERVER=1
|
||||
Environment=PULSE_RUNTIME_PATH=/tmp/runtime-buttontask/pulse
|
||||
ExecStart=/opt/buttontask/current/ButtonTask --config /opt/buttontask/config/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ethernet network configuration via NetworkManager (nmcli).
|
||||
"""Ethernet / Wi-Fi network configuration via NetworkManager (nmcli).
|
||||
|
||||
Reading current state is done directly (read-only nmcli). Applying a new
|
||||
configuration is delegated to the privileged helper script `bt-netconfig`
|
||||
@@ -19,6 +19,7 @@ from paths import scripts_dir
|
||||
network_bp = Blueprint("network", __name__)
|
||||
|
||||
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
|
||||
_SSID_RE = re.compile(r"^[\x20-\x7e]{1,32}$")
|
||||
|
||||
|
||||
def network_config_path():
|
||||
@@ -45,8 +46,8 @@ def _run(args, timeout=10):
|
||||
return 124, "", "timeout"
|
||||
|
||||
|
||||
def list_ethernet_devices():
|
||||
"""Return [{device, state, connection}] for ethernet interfaces."""
|
||||
def list_network_devices():
|
||||
"""Return [{device, type, state, connection}] for ethernet and wifi."""
|
||||
rc, out, _ = _run([
|
||||
"nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status",
|
||||
])
|
||||
@@ -58,21 +59,31 @@ def list_ethernet_devices():
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
device, dtype, state, connection = parts[0], parts[1], parts[2], parts[3]
|
||||
if dtype != "ethernet":
|
||||
if dtype not in ("ethernet", "wifi"):
|
||||
continue
|
||||
devices.append({"device": device, "state": state, "connection": connection})
|
||||
devices.append({
|
||||
"device": device,
|
||||
"type": dtype,
|
||||
"state": state,
|
||||
"connection": connection,
|
||||
})
|
||||
return devices
|
||||
|
||||
|
||||
def list_ethernet_devices():
|
||||
"""Backward-compatible alias — ethernet only. """
|
||||
return [d for d in list_network_devices() if d.get("type") == "ethernet"]
|
||||
|
||||
|
||||
def device_details(device):
|
||||
"""Return current IP4 config for a device."""
|
||||
rc, out, _ = _run([
|
||||
"nmcli", "-t", "-f",
|
||||
"IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.CONNECTION",
|
||||
"IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.CONNECTION,GENERAL.TYPE",
|
||||
"device", "show", device,
|
||||
])
|
||||
info = {"device": device, "addresses": [], "gateway": "", "dns": [],
|
||||
"connection": ""}
|
||||
"connection": "", "type": ""}
|
||||
if rc != 0:
|
||||
return info
|
||||
for line in out.splitlines():
|
||||
@@ -90,14 +101,24 @@ def device_details(device):
|
||||
info["dns"].append(val)
|
||||
elif key == "GENERAL.CONNECTION":
|
||||
info["connection"] = val
|
||||
elif key == "GENERAL.TYPE":
|
||||
# nmcli may report "wifi" / "802-11-wireless" / "ethernet"
|
||||
info["type"] = "wifi" if "wireless" in val or val == "wifi" else (
|
||||
"ethernet" if "ethernet" in val else val
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def current_state():
|
||||
devices = list_ethernet_devices()
|
||||
devices = list_network_devices()
|
||||
for d in devices:
|
||||
d.update({k: v for k, v in device_details(d["device"]).items()
|
||||
if k != "device"})
|
||||
details = device_details(d["device"])
|
||||
for k, v in details.items():
|
||||
if k == "device":
|
||||
continue
|
||||
if k == "type" and d.get("type"):
|
||||
continue
|
||||
d[k] = v
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@@ -116,7 +137,22 @@ def _validate_payload(data):
|
||||
mode = (data.get("mode") or "dhcp").strip().lower()
|
||||
if mode not in ("dhcp", "static"):
|
||||
return None, "mode must be dhcp or static"
|
||||
payload = {"iface": iface, "mode": mode}
|
||||
net_type = (data.get("type") or data.get("netType") or "ethernet").strip().lower()
|
||||
if net_type not in ("ethernet", "wifi"):
|
||||
return None, "type must be ethernet or wifi"
|
||||
|
||||
payload = {"iface": iface, "mode": mode, "type": net_type}
|
||||
|
||||
if net_type == "wifi":
|
||||
ssid = (data.get("ssid") or "").strip()
|
||||
if not ssid or not _SSID_RE.match(ssid):
|
||||
return None, "invalid ssid"
|
||||
payload["ssid"] = ssid
|
||||
password = data.get("password")
|
||||
if password is None:
|
||||
password = data.get("wifiPassword") or ""
|
||||
payload["password"] = str(password)
|
||||
|
||||
if mode == "static":
|
||||
address = (data.get("address") or "").strip()
|
||||
if not _valid_ip(address):
|
||||
@@ -147,7 +183,15 @@ def _validate_payload(data):
|
||||
@network_bp.route("/api/network", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_network():
|
||||
return jsonify(current_state())
|
||||
state = current_state()
|
||||
cfg_path = network_config_path()
|
||||
if cfg_path.is_file():
|
||||
try:
|
||||
with cfg_path.open(encoding="utf-8") as f:
|
||||
state["saved"] = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return jsonify(state)
|
||||
|
||||
|
||||
@network_bp.route("/api/network", methods=["PUT"])
|
||||
@@ -163,10 +207,46 @@ def api_set_network():
|
||||
if payload["mode"] == "static":
|
||||
args += [payload["address"], str(payload["prefix"]),
|
||||
payload.get("gateway", ""), payload.get("dns", "")]
|
||||
if payload.get("type") == "wifi":
|
||||
args += ["wifi", payload["ssid"], payload.get("password", "")]
|
||||
|
||||
rc, out, err_out = _run(args, timeout=60)
|
||||
if rc != 0:
|
||||
return jsonify({"ok": False, "error": err_out.strip() or out.strip()
|
||||
or f"exit {rc}"}), 500
|
||||
|
||||
# Persist without storing empty wifi password overwrite if omitted? store as given.
|
||||
save_payload = {k: v for k, v in payload.items()}
|
||||
save_payload.pop("disabled", None)
|
||||
save_network_config(save_payload)
|
||||
return jsonify({"ok": True, "output": out.strip()})
|
||||
|
||||
|
||||
@network_bp.route("/api/network/disconnect", methods=["POST"])
|
||||
@login_required
|
||||
def api_disconnect_network():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
iface = (data.get("iface") or "").strip()
|
||||
if not _IFACE_RE.match(iface):
|
||||
abort(400, "invalid iface")
|
||||
|
||||
script = str(scripts_dir() / "bt-netconfig")
|
||||
args = ["sudo", "-n", script, iface, "disconnect"]
|
||||
rc, out, err_out = _run(args, timeout=30)
|
||||
if rc != 0:
|
||||
return jsonify({"ok": False, "error": err_out.strip() or out.strip()
|
||||
or f"exit {rc}"}), 500
|
||||
save_network_config(payload)
|
||||
|
||||
# If saved config pointed at this iface, clear it so boot won't re-apply.
|
||||
cfg_path = network_config_path()
|
||||
if cfg_path.is_file():
|
||||
try:
|
||||
with cfg_path.open(encoding="utf-8") as f:
|
||||
saved = json.load(f)
|
||||
if (saved.get("iface") or "").strip() == iface:
|
||||
saved["disabled"] = True
|
||||
save_network_config(saved)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return jsonify({"ok": True, "output": out.strip()})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# bt-netconfig - apply Ethernet configuration via NetworkManager.
|
||||
# bt-netconfig - apply Ethernet or Wi-Fi configuration via NetworkManager.
|
||||
#
|
||||
# Usage:
|
||||
# bt-netconfig <iface> dhcp
|
||||
# bt-netconfig <iface> static <address> <prefix> [gateway] [dns_csv]
|
||||
# bt-netconfig <iface> dhcp wifi <ssid> [password]
|
||||
# bt-netconfig <iface> static <address> <prefix> [gateway] [dns_csv] wifi <ssid> [password]
|
||||
#
|
||||
# Intended to be invoked through sudo by the (unprivileged) web configurator.
|
||||
# Do NOT use ip/ifconfig directly — NetworkManager will undo manual changes.
|
||||
@@ -16,35 +18,122 @@ set -euo pipefail
|
||||
|
||||
err() { echo "bt-netconfig: $*" >&2; exit 1; }
|
||||
|
||||
[ "$#" -ge 2 ] || err "usage: bt-netconfig <iface> <dhcp|static> ..."
|
||||
[ "$#" -ge 2 ] || err "usage: bt-netconfig <iface> <dhcp|static|disconnect> [args...] [wifi <ssid> [password]]"
|
||||
|
||||
IFACE="$1"
|
||||
MODE="$2"
|
||||
shift 2
|
||||
PROFILE="buttontask-$IFACE"
|
||||
BOOT="${BUTTONTASK_NET_BOOT:-0}"
|
||||
ACTIVATE_TIMEOUT="${BUTTONTASK_NET_ACTIVATE_TIMEOUT:-30}"
|
||||
NET_TYPE="ethernet"
|
||||
SSID=""
|
||||
WIFI_PASS=""
|
||||
|
||||
[[ "$IFACE" =~ ^[A-Za-z0-9_.:-]{1,32}$ ]] || err "invalid interface name"
|
||||
command -v nmcli >/dev/null 2>&1 || err "nmcli not found"
|
||||
|
||||
# Ensure NM manages this interface (manual ip addr fights with NM).
|
||||
# Fast path: disconnect / disable interface without reconfiguring.
|
||||
if [ "$MODE" = "disconnect" ]; then
|
||||
nmcli device disconnect "$IFACE" 2>/dev/null || true
|
||||
# Turn off autoconnect on our profile so it does not come back immediately.
|
||||
if nmcli -t -f NAME connection show | grep -Fxq "$PROFILE"; then
|
||||
nmcli connection modify "$PROFILE" connection.autoconnect no 2>/dev/null || true
|
||||
nmcli connection down "$PROFILE" 2>/dev/null || true
|
||||
fi
|
||||
# Also disable autoconnect on other profiles bound to this iface.
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
bound="$(nmcli -g connection.interface-name connection show "$name" 2>/dev/null || true)"
|
||||
if [ "$bound" = "$IFACE" ] || [ "$name" = "$PROFILE" ]; then
|
||||
nmcli connection modify "$name" connection.autoconnect no 2>/dev/null || true
|
||||
fi
|
||||
done < <(nmcli -t -f NAME connection show 2>/dev/null || true)
|
||||
echo "disconnected $IFACE (autoconnect off)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ "$#" -ge 0 ] || true
|
||||
# Parse remaining args: optional static fields, then optional "wifi ssid [password]"
|
||||
ADDR=""
|
||||
PREFIX=""
|
||||
GW=""
|
||||
DNS=""
|
||||
ARGS=("$@")
|
||||
i=0
|
||||
n=${#ARGS[@]}
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
if [ "${ARGS[$i]}" = "wifi" ]; then
|
||||
NET_TYPE="wifi"
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "$n" ] || err "wifi requires <ssid>"
|
||||
SSID="${ARGS[$i]}"
|
||||
i=$((i + 1))
|
||||
if [ "$i" -lt "$n" ]; then
|
||||
WIFI_PASS="${ARGS[$i]}"
|
||||
i=$((i + 1))
|
||||
fi
|
||||
break
|
||||
fi
|
||||
case "$MODE" in
|
||||
static)
|
||||
if [ -z "$ADDR" ]; then ADDR="${ARGS[$i]}"
|
||||
elif [ -z "$PREFIX" ]; then PREFIX="${ARGS[$i]}"
|
||||
elif [ -z "$GW" ]; then GW="${ARGS[$i]}"
|
||||
elif [ -z "$DNS" ]; then DNS="${ARGS[$i]}"
|
||||
else err "unexpected argument: ${ARGS[$i]}"
|
||||
fi
|
||||
;;
|
||||
dhcp)
|
||||
err "unexpected argument for dhcp: ${ARGS[$i]}"
|
||||
;;
|
||||
*)
|
||||
err "mode must be dhcp, static or disconnect"
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
if [ "$NET_TYPE" = "wifi" ]; then
|
||||
[ -n "$SSID" ] || err "wifi requires <ssid>"
|
||||
fi
|
||||
|
||||
# Ensure NM manages this interface.
|
||||
nmcli device set "$IFACE" managed yes 2>/dev/null || true
|
||||
|
||||
# Use a dedicated profile per interface so we never fight "Wired connection 1".
|
||||
if ! nmcli -t -f NAME connection show | grep -Fxq "$PROFILE"; then
|
||||
nmcli connection add type ethernet ifname "$IFACE" con-name "$PROFILE" \
|
||||
connection.autoconnect yes >/dev/null
|
||||
fi
|
||||
# Create dedicated profile of the right type.
|
||||
ensure_profile() {
|
||||
if nmcli -t -f NAME connection show | grep -Fxq "$PROFILE"; then
|
||||
local typ
|
||||
typ="$(nmcli -g connection.type connection show "$PROFILE" 2>/dev/null || true)"
|
||||
if [ "$NET_TYPE" = "wifi" ] && [ "$typ" != "802-11-wireless" ]; then
|
||||
nmcli connection delete "$PROFILE" >/dev/null 2>&1 || true
|
||||
elif [ "$NET_TYPE" = "ethernet" ] && [ "$typ" != "802-3-ethernet" ]; then
|
||||
nmcli connection delete "$PROFILE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
if ! nmcli -t -f NAME connection show | grep -Fxq "$PROFILE"; then
|
||||
if [ "$NET_TYPE" = "wifi" ]; then
|
||||
nmcli connection add type wifi ifname "$IFACE" con-name "$PROFILE" \
|
||||
ssid "$SSID" connection.autoconnect yes >/dev/null
|
||||
else
|
||||
nmcli connection add type ethernet ifname "$IFACE" con-name "$PROFILE" \
|
||||
connection.autoconnect yes >/dev/null
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_profile
|
||||
CON="$PROFILE"
|
||||
|
||||
# Disable autoconnect on other ethernet profiles for this interface.
|
||||
# Disable autoconnect on other profiles for this interface (same media type).
|
||||
disable_other_profiles() {
|
||||
local iface="$1" con="$2" name bound typ
|
||||
local iface="$1" con="$2" want_type="$3" name bound typ
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
[ "$name" = "$con" ] && continue
|
||||
typ="$(nmcli -g connection.type connection show "$name" 2>/dev/null || true)"
|
||||
[ "$typ" = "802-3-ethernet" ] || continue
|
||||
[ "$typ" = "$want_type" ] || continue
|
||||
bound="$(nmcli -g connection.interface-name connection show "$name" 2>/dev/null || true)"
|
||||
if [ -n "$bound" ] && [ "$bound" != "--" ] && [ "$bound" != "$iface" ]; then
|
||||
continue
|
||||
@@ -53,12 +142,30 @@ disable_other_profiles() {
|
||||
done < <(nmcli -t -f NAME connection show 2>/dev/null || true)
|
||||
}
|
||||
|
||||
disable_other_profiles "$IFACE" "$CON"
|
||||
|
||||
nmcli connection modify "$CON" \
|
||||
connection.interface-name "$IFACE" \
|
||||
connection.autoconnect yes \
|
||||
connection.autoconnect-priority 100
|
||||
if [ "$NET_TYPE" = "wifi" ]; then
|
||||
disable_other_profiles "$IFACE" "$CON" "802-11-wireless"
|
||||
nmcli connection modify "$CON" \
|
||||
connection.interface-name "$IFACE" \
|
||||
connection.autoconnect yes \
|
||||
connection.autoconnect-priority 100 \
|
||||
wifi.ssid "$SSID"
|
||||
if [ -n "$WIFI_PASS" ]; then
|
||||
nmcli connection modify "$CON" \
|
||||
wifi-sec.key-mgmt wpa-psk \
|
||||
wifi-sec.psk "$WIFI_PASS"
|
||||
else
|
||||
# Open network
|
||||
nmcli connection modify "$CON" \
|
||||
wifi-sec.key-mgmt none 2>/dev/null || true
|
||||
nmcli connection modify "$CON" remove wifi-sec.psk 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
disable_other_profiles "$IFACE" "$CON" "802-3-ethernet"
|
||||
nmcli connection modify "$CON" \
|
||||
connection.interface-name "$IFACE" \
|
||||
connection.autoconnect yes \
|
||||
connection.autoconnect-priority 100
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
dhcp)
|
||||
@@ -71,11 +178,7 @@ case "$MODE" in
|
||||
ipv4.never-default no
|
||||
;;
|
||||
static)
|
||||
[ "$#" -ge 4 ] || err "static requires <address> <prefix>"
|
||||
ADDR="$3"
|
||||
PREFIX="$4"
|
||||
GW="${5:-}"
|
||||
DNS="${6:-}"
|
||||
[ -n "$ADDR" ] && [ -n "$PREFIX" ] || err "static requires <address> <prefix>"
|
||||
[[ "$PREFIX" =~ ^[0-9]{1,2}$ ]] && [ "$PREFIX" -ge 1 ] && [ "$PREFIX" -le 32 ] \
|
||||
|| err "invalid prefix"
|
||||
if [ -n "$GW" ]; then
|
||||
@@ -101,7 +204,6 @@ case "$MODE" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Drop the active profile so the new settings apply cleanly.
|
||||
nmcli device disconnect "$IFACE" 2>/dev/null || true
|
||||
nmcli connection down "$CON" 2>/dev/null || true
|
||||
|
||||
@@ -126,8 +228,7 @@ if ! activate_connection "$CON" "$IFACE"; then
|
||||
err "connection activation failed or timed out after ${ACTIVATE_TIMEOUT}s"
|
||||
fi
|
||||
|
||||
# Brief settle, then report what NM actually applied.
|
||||
sleep 1
|
||||
APPLIED="$(nmcli -t -f IP4.ADDRESS device show "$IFACE" 2>/dev/null \
|
||||
| sed 's/^IP4.ADDRESS://' | head -n1 | tr '\n' ' ')"
|
||||
echo "applied $MODE on $IFACE ($CON)${APPLIED:+ ip=$APPLIED}"
|
||||
echo "applied $MODE on $IFACE ($CON type=$NET_TYPE)${SSID:+ ssid=$SSID}${APPLIED:+ ip=$APPLIED}"
|
||||
|
||||
@@ -56,6 +56,9 @@ import json, os, subprocess, sys
|
||||
cfg_path, script = sys.argv[1], sys.argv[2]
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
if cfg.get("disabled"):
|
||||
print("bt-netconfig-boot: saved config marked disabled, skip", flush=True)
|
||||
sys.exit(0)
|
||||
iface = (cfg.get("iface") or "").strip()
|
||||
mode = (cfg.get("mode") or "").strip()
|
||||
if not iface or mode not in ("dhcp", "static"):
|
||||
@@ -68,6 +71,12 @@ if mode == "static":
|
||||
cfg.get("gateway", ""),
|
||||
cfg.get("dns", ""),
|
||||
]
|
||||
net_type = (cfg.get("type") or "ethernet").strip().lower()
|
||||
if net_type == "wifi":
|
||||
ssid = (cfg.get("ssid") or "").strip()
|
||||
if not ssid:
|
||||
sys.exit(0)
|
||||
args += ["wifi", ssid, cfg.get("password") or ""]
|
||||
env = os.environ.copy()
|
||||
env.setdefault("BUTTONTASK_NET_BOOT", "1")
|
||||
raise SystemExit(subprocess.run(args, env=env).returncode)
|
||||
|
||||
@@ -312,28 +312,63 @@ $("#b-rc-add-rule")?.addEventListener("click", () => {
|
||||
|
||||
function syncHoldRow() {
|
||||
const mode = $("#b-trigger-mode").value;
|
||||
$("#b-hold-row").style.display = mode === "click" ? "none" : "";
|
||||
const isVoice = $("#b-kind")?.value === "voice";
|
||||
$("#b-hold-row").style.display = (mode === "click" || isVoice) ? "none" : "";
|
||||
$("#b-hold-val").textContent = $("#b-hold").value;
|
||||
}
|
||||
|
||||
function syncKindUi() {
|
||||
const isVoice = $("#b-kind")?.value === "voice";
|
||||
const voiceBlock = $("#b-voice-block");
|
||||
const latchBlock = $("#b-latch-block");
|
||||
const rcBlock = $("#b-response-check-block");
|
||||
const typeRow = $("#b-type-row");
|
||||
if (voiceBlock) voiceBlock.style.display = isVoice ? "" : "none";
|
||||
if (latchBlock) latchBlock.style.display = isVoice ? "none" : "";
|
||||
if (rcBlock) rcBlock.style.display = isVoice ? "none" : "";
|
||||
if (typeRow) typeRow.style.display = isVoice ? "none" : "";
|
||||
const triggerLabel = $("#b-trigger-label");
|
||||
if (triggerLabel) {
|
||||
const sel = $("#b-trigger-mode");
|
||||
if (sel && isVoice) {
|
||||
sel.options[0].text = "Удержание + речь";
|
||||
sel.options[1].text = "Клик старт/стоп";
|
||||
} else if (sel) {
|
||||
sel.options[0].text = "Удержание";
|
||||
sel.options[1].text = "Клик";
|
||||
}
|
||||
}
|
||||
if (isVoice && $("#b-action-timeout") && Number($("#b-action-timeout").value) === 7000) {
|
||||
$("#b-action-timeout").value = 60000;
|
||||
}
|
||||
syncHoldRow();
|
||||
syncLatchUi();
|
||||
}
|
||||
|
||||
$("#b-kind")?.addEventListener("change", syncKindUi);
|
||||
|
||||
function openDialogFor(btn) {
|
||||
$("#btn-dialog-title").textContent = btn ? "Редактирование" : "Новая кнопка";
|
||||
$("#b-id").value = btn?.id || "";
|
||||
$("#b-label").value = btn?.label || "";
|
||||
$("#b-kind").value = btn?.kind === "voice" ? "voice" : "press";
|
||||
$("#b-icon").value = btn?.iconPath || "";
|
||||
$("#b-type").value = btn?.action?.type || "http_get";
|
||||
$("#b-url").value = btn?.action?.url || "";
|
||||
$("#b-action-timeout").value = btn?.action?.timeoutMs ?? 7000;
|
||||
$("#b-action-timeout").value = btn?.action?.timeoutMs ?? (btn?.kind === "voice" ? 60000 : 7000);
|
||||
$("#b-success").value = btn?.feedback?.successText || "";
|
||||
$("#b-error").value = btn?.feedback?.errorText || "Ошибка";
|
||||
$("#b-pending").value = btn?.feedback?.pendingText ?? "...";
|
||||
$("#b-pending").value = btn?.feedback?.pendingText ?? (btn?.kind === "voice" ? "Слушаю..." : "...");
|
||||
$("#b-color").value = btn?.color || "#7b007b";
|
||||
$("#b-trigger-mode").value = btn?.trigger?.mode || "hold";
|
||||
$("#b-hold").value = btn?.trigger?.holdMs || 800;
|
||||
$("#b-cancel-url").value = btn?.voice?.cancelUrl || "";
|
||||
$("#b-source-id").value = btn?.voice?.sourceId || "panel-01";
|
||||
$("#b-api-key").value = btn?.action?.headers?.["X-API-Key"] || "";
|
||||
$("#b-max-record").value = btn?.voice?.maxRecordMs || 30000;
|
||||
fillLatchResetForm(btn?.latchReset);
|
||||
fillResponseCheckForm(btn?.action?.responseCheck);
|
||||
syncHoldRow();
|
||||
syncLatchUi();
|
||||
syncKindUi();
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
@@ -364,22 +399,38 @@ form?.addEventListener("submit", async (e) => {
|
||||
if (e.submitter && e.submitter.value === "cancel") return;
|
||||
e.preventDefault();
|
||||
const id = $("#b-id").value;
|
||||
const latchReset = readLatchResetFromForm();
|
||||
const kind = $("#b-kind")?.value || "press";
|
||||
const latchReset = kind === "voice"
|
||||
? { enabled: false }
|
||||
: readLatchResetFromForm();
|
||||
const payload = {
|
||||
label: $("#b-label").value,
|
||||
iconPath: $("#b-icon").value,
|
||||
actionType: $("#b-type").value,
|
||||
kind,
|
||||
actionType: kind === "voice" ? "http_post" : $("#b-type").value,
|
||||
url: $("#b-url").value,
|
||||
timeoutMs: parseInt($("#b-action-timeout").value, 10) || 7000,
|
||||
timeoutMs: parseInt($("#b-action-timeout").value, 10) || (kind === "voice" ? 60000 : 7000),
|
||||
successText: $("#b-success").value,
|
||||
errorText: $("#b-error").value,
|
||||
pendingText: $("#b-pending").value || "...",
|
||||
pendingText: $("#b-pending").value || (kind === "voice" ? "Слушаю..." : "..."),
|
||||
color: $("#b-color").value,
|
||||
triggerMode: $("#b-trigger-mode").value,
|
||||
holdMs: parseInt($("#b-hold").value, 10),
|
||||
latchReset,
|
||||
responseCheck: latchReset.enabled ? { enabled: false } : readResponseCheckFromForm(),
|
||||
responseCheck: (kind === "voice" || latchReset.enabled)
|
||||
? { enabled: false }
|
||||
: readResponseCheckFromForm(),
|
||||
};
|
||||
if (kind === "voice") {
|
||||
payload.voice = {
|
||||
cancelUrl: ($("#b-cancel-url")?.value || "").trim(),
|
||||
sourceId: ($("#b-source-id")?.value || "").trim() || "panel-01",
|
||||
maxRecordMs: parseInt($("#b-max-record")?.value, 10) || 30000,
|
||||
};
|
||||
const apiKey = ($("#b-api-key")?.value || "").trim();
|
||||
payload.headers = apiKey ? { "X-API-Key": apiKey } : {};
|
||||
payload.apiKey = apiKey;
|
||||
}
|
||||
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
|
||||
else await api("POST", "/api/buttons", payload);
|
||||
dlg.close();
|
||||
@@ -394,6 +445,24 @@ function toggleStatic() {
|
||||
}
|
||||
$("#net-mode")?.addEventListener("change", toggleStatic);
|
||||
|
||||
function deviceTypeMap(devs) {
|
||||
const m = {};
|
||||
(devs || []).forEach(d => { m[d.device] = d.type || "ethernet"; });
|
||||
return m;
|
||||
}
|
||||
|
||||
let _netDeviceTypes = {};
|
||||
|
||||
function toggleWifiFields() {
|
||||
const wrap = $("#net-mode")?.closest(".grid-form");
|
||||
if (!wrap) return;
|
||||
const iface = $("#net-iface")?.value;
|
||||
const isWifi = (_netDeviceTypes[iface] || "ethernet") === "wifi";
|
||||
wrap.classList.toggle("show-wifi", isWifi);
|
||||
}
|
||||
|
||||
$("#net-iface")?.addEventListener("change", toggleWifiFields);
|
||||
|
||||
async function loadNetwork() {
|
||||
const statusEl = $("#net-status");
|
||||
const ifaceSel = $("#net-iface");
|
||||
@@ -402,25 +471,52 @@ async function loadNetwork() {
|
||||
try {
|
||||
const data = await api("GET", "/api/network");
|
||||
const devs = data.devices || [];
|
||||
_netDeviceTypes = deviceTypeMap(devs);
|
||||
if (!devs.length) {
|
||||
statusEl.innerHTML = "<p class='muted'>Ethernet-интерфейсы не найдены (или nmcli недоступен).</p>";
|
||||
statusEl.innerHTML = "<p class='muted'>Сетевые интерфейсы не найдены (или nmcli недоступен).</p>";
|
||||
} else {
|
||||
statusEl.innerHTML = devs.map(d => {
|
||||
const up = (d.state || "").includes("connected") && !(d.state || "").includes("dis");
|
||||
const badge = up ? "<span class='badge up'>up</span>" : "<span class='badge down'>down</span>";
|
||||
const addr = (d.addresses || []).join(", ") || "—";
|
||||
const typ = d.type || "ethernet";
|
||||
return `<div class="net-device">
|
||||
<div class="name">${d.device} ${badge}</div>
|
||||
<div class="name">${d.device} <span class="muted">[${typ}]</span> ${badge}</div>
|
||||
<div class="detail">Соединение: ${d.connection || "—"}</div>
|
||||
<div class="detail">IP: ${addr}</div>
|
||||
<div class="detail">Шлюз: ${d.gateway || "—"} · DNS: ${(d.dns||[]).join(", ") || "—"}</div>
|
||||
<button type="button" class="ghost net-disconnect" data-iface="${d.device}">Отключить</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
statusEl.querySelectorAll(".net-disconnect").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const iface = btn.dataset.iface;
|
||||
if (!confirm(`Отключить ${iface}?`)) return;
|
||||
try {
|
||||
await api("POST", "/api/network/disconnect", { iface });
|
||||
flash(`Интерфейс ${iface} отключён`);
|
||||
setTimeout(loadNetwork, 1000);
|
||||
} catch (e) { flash(e.message, true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
if (ifaceSel) {
|
||||
const prev = ifaceSel.value;
|
||||
ifaceSel.innerHTML = devs.map(d => `<option value="${d.device}">${d.device}</option>`).join("");
|
||||
if (prev) ifaceSel.value = prev;
|
||||
const saved = data.saved || {};
|
||||
ifaceSel.innerHTML = devs.map(d =>
|
||||
`<option value="${d.device}">${d.device} (${d.type || "?"})</option>`
|
||||
).join("");
|
||||
if (saved.iface) ifaceSel.value = saved.iface;
|
||||
else if (prev) ifaceSel.value = prev;
|
||||
if (saved.ssid) $("#net-ssid").value = saved.ssid;
|
||||
if (saved.password != null) $("#net-password").value = saved.password;
|
||||
if (saved.mode) $("#net-mode").value = saved.mode;
|
||||
if (saved.address) $("#net-address").value = saved.address;
|
||||
if (saved.prefix) $("#net-prefix").value = saved.prefix;
|
||||
if (saved.gateway) $("#net-gateway").value = saved.gateway;
|
||||
if (saved.dns) $("#net-dns").value = saved.dns;
|
||||
toggleStatic();
|
||||
toggleWifiFields();
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.innerHTML = `<p class='warn'>${e.message}</p>`;
|
||||
@@ -431,7 +527,14 @@ $("#net-refresh")?.addEventListener("click", loadNetwork);
|
||||
|
||||
$("#net-apply")?.addEventListener("click", async () => {
|
||||
const mode = $("#net-mode").value;
|
||||
const payload = { iface: $("#net-iface").value, mode };
|
||||
const iface = $("#net-iface").value;
|
||||
const netType = _netDeviceTypes[iface] || "ethernet";
|
||||
const payload = { iface, mode, type: netType };
|
||||
if (netType === "wifi") {
|
||||
payload.ssid = ($("#net-ssid")?.value || "").trim();
|
||||
payload.password = $("#net-password")?.value || "";
|
||||
if (!payload.ssid) { flash("Укажите SSID Wi‑Fi", true); return; }
|
||||
}
|
||||
if (mode === "static") {
|
||||
payload.address = $("#net-address").value.trim();
|
||||
payload.prefix = parseInt($("#net-prefix").value, 10);
|
||||
|
||||
@@ -275,6 +275,8 @@ button.icon-only { padding: 6px 9px; line-height: 1; }
|
||||
.badge.down { background: rgba(185,28,28,0.2); color: #f87171; }
|
||||
.static-only { display: none; }
|
||||
.grid-form.show-static .static-only { display: flex; }
|
||||
.wifi-only { display: none; }
|
||||
.grid-form.show-wifi .wifi-only { display: flex; }
|
||||
|
||||
/* ---------- Update ---------- */
|
||||
.update-layout {
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<section class="panel" id="tab-network">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Ethernet</h2>
|
||||
<h2>Сеть (Ethernet / Wi‑Fi)</h2>
|
||||
<button id="net-refresh" class="ghost">↻ Обновить</button>
|
||||
</div>
|
||||
<div id="net-status" class="net-status">Загрузка состояния…</div>
|
||||
@@ -253,6 +253,12 @@
|
||||
<option value="static">Статический IP</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="wifi-only">SSID Wi‑Fi
|
||||
<input type="text" id="net-ssid" placeholder="Имя сети" maxlength="32">
|
||||
</label>
|
||||
<label class="wifi-only">Пароль Wi‑Fi
|
||||
<input type="password" id="net-password" placeholder="Пустой = открытая сеть">
|
||||
</label>
|
||||
<label class="static-only">IP-адрес <input type="text" id="net-address" placeholder="192.168.1.50"></label>
|
||||
<label class="static-only">Маска (префикс) <input type="number" id="net-prefix" min="1" max="32" value="24"></label>
|
||||
<label class="static-only">Шлюз <input type="text" id="net-gateway" placeholder="192.168.1.1"></label>
|
||||
@@ -425,6 +431,12 @@
|
||||
<h3 id="btn-dialog-title">Кнопка</h3>
|
||||
<input type="hidden" id="b-id">
|
||||
<label>Подпись <input type="text" id="b-label" required></label>
|
||||
<label>Тип кнопки
|
||||
<select id="b-kind">
|
||||
<option value="press">Нажатие</option>
|
||||
<option value="voice">Голос (запись)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Иконка
|
||||
<select id="b-icon">
|
||||
<option value="">— нет —</option>
|
||||
@@ -433,17 +445,27 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Метод
|
||||
<label id="b-type-row">Метод
|
||||
<select id="b-type">
|
||||
<option value="http_get">http_get</option>
|
||||
<option value="http_post">http_post</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>URL <input type="text" id="b-url" required></label>
|
||||
<label>URL <input type="text" id="b-url" required placeholder="http://host/api/..."></label>
|
||||
<label>Ожидание ответа, мс
|
||||
<input type="number" id="b-action-timeout" min="1000" max="600000" step="500" value="7000">
|
||||
</label>
|
||||
<p class="hint b-action-timeout-hint">Таймаут одного HTTP-запроса. По умолчанию 7000 мс (7 с). При «Мгновенном отклике» влияет только на фоновый запрос и лог.</p>
|
||||
<p class="hint b-action-timeout-hint">Таймаут одного HTTP-запроса. Для голоса обычно 60000.</p>
|
||||
|
||||
<div class="voice-block" id="b-voice-block" style="display:none">
|
||||
<label>URL отмены (cancel) <input type="text" id="b-cancel-url" placeholder="http://host/api/v1/cancel"></label>
|
||||
<label>source_id <input type="text" id="b-source-id" placeholder="panel-01"></label>
|
||||
<label>X-API-Key <input type="password" id="b-api-key" placeholder="ключ API сервера" autocomplete="off"></label>
|
||||
<label>Макс. запись, мс
|
||||
<input type="number" id="b-max-record" min="1000" max="120000" step="1000" value="30000">
|
||||
</label>
|
||||
<p class="hint">Удержание: запись пока зажата. Клик: старт/стоп. Повторное нажатие на горящей кнопке — отмена на cancel URL. Ключ уходит в заголовке X-API-Key.</p>
|
||||
</div>
|
||||
|
||||
<div class="latch-reset-block" id="b-latch-block">
|
||||
<label class="check">
|
||||
@@ -466,7 +488,7 @@
|
||||
<label>Текст ошибки <input type="text" id="b-error" value="Ошибка"></label>
|
||||
<label>Текст ожидания <input type="text" id="b-pending" value="..."></label>
|
||||
<label>Цвет кнопки <input type="color" id="b-color" value="#7b007b"></label>
|
||||
<label>Срабатывание
|
||||
<label id="b-trigger-label">Срабатывание
|
||||
<select id="b-trigger-mode">
|
||||
<option value="hold">Удержание</option>
|
||||
<option value="click">Клик</option>
|
||||
|
||||
Reference in New Issue
Block a user