This commit is contained in:
2026-03-07 22:43:08 +03:00
parent 61e7e3af56
commit 7a86519314
17 changed files with 2316 additions and 1 deletions

737
yastation.py Normal file
View File

@@ -0,0 +1,737 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import asyncio
import json
import os
import ssl
import time
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import aiohttp
from aiohttp import web
from zeroconf import ServiceBrowser, Zeroconf
MDNS_SERVICE = "_yandexio._tcp.local."
QUASAR_TOKEN_URL = "https://quasar.yandex.net/glagol/token"
YANDEX_INFO_URL = "https://api.iot.yandex.net/v1.0/user/info"
@dataclass
class Station:
device_id: str
platform: str
host: str
port: int
name: str = ""
class _Listener:
def __init__(self):
self.stations: Dict[str, Station] = {}
def remove_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None:
pass
def add_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None:
self._handle(zeroconf, service_type, name)
def update_service(self, zeroconf: Zeroconf, service_type: str, name: str) -> None:
self._handle(zeroconf, service_type, name)
def _handle(self, zeroconf: Zeroconf, service_type: str, name: str) -> None:
info = zeroconf.get_service_info(service_type, name)
if not info:
return
props: Dict[str, Any] = {}
for k, v in (info.properties or {}).items():
try:
kk = k.decode("utf-8") if isinstance(k, (bytes, bytearray)) else str(k)
vv = v.decode("utf-8", errors="ignore") if isinstance(v, (bytes, bytearray)) else v
props[kk] = vv
except Exception:
continue
device_id = str(props.get("deviceId") or props.get("device_id") or "").strip()
platform = str(props.get("platform") or "").strip()
if not device_id or not platform:
return
try:
addr = info.addresses[0]
host = ".".join(str(b) for b in addr)
except Exception:
return
port = int(info.port or 0)
if not port:
return
name_friendly = str(props.get("name") or props.get("deviceName") or name).strip()
self.stations[device_id] = Station(
device_id=device_id,
platform=platform,
host=host,
port=port,
name=name_friendly,
)
async def discover(timeout_sec: float = 2.5) -> List[Station]:
zc = Zeroconf()
listener = _Listener()
browser = ServiceBrowser(zc, MDNS_SERVICE, listener)
try:
await asyncio.sleep(timeout_sec)
finally:
try:
browser.cancel()
except Exception:
pass
try:
zc.close()
except Exception:
pass
return list(listener.stations.values())
def _ssl_ctx() -> ssl.SSLContext:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _volume01_from_0_100(v: float) -> float:
if v < 0 or v > 100:
raise RuntimeError("volume must be 0..100")
return round(v / 100.0, 3)
def pick_station(stations: List[Station], selector: str) -> Station:
selector = (selector or "").strip()
if not selector:
raise RuntimeError("empty selector")
for s in stations:
if s.device_id == selector:
return s
for s in stations:
if s.host == selector or f"{s.host}:{s.port}" == selector:
return s
low = selector.lower()
for s in stations:
if low in (s.name or "").lower():
return s
raise RuntimeError(f"station not found by selector: {selector}")
async def get_conversation_token(session: aiohttp.ClientSession, oauth_token: str, station: Station) -> str:
params = {"device_id": station.device_id, "platform": station.platform}
headers = {"Authorization": f"oauth {oauth_token}"}
async with session.get(QUASAR_TOKEN_URL, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as r:
text = await r.text()
try:
data = json.loads(text)
except Exception:
raise RuntimeError(f"Quasar token: bad json: {text[:200]}")
if data.get("status") != "ok" or not data.get("token"):
raise RuntimeError(f"Quasar token: unexpected response: {data}")
return str(data["token"])
async def ws_send(session: aiohttp.ClientSession, station: Station, conv_token: str, payload: dict, wait_sec: float = 5.0) -> dict:
url = f"wss://{station.host}:{station.port}"
req_id = str(uuid.uuid4())
msg = {
"conversationToken": conv_token,
"id": req_id,
"payload": payload,
"sentTime": int(round(time.time() * 1000)),
}
async with session.ws_connect(url, ssl=_ssl_ctx(), heartbeat=55, timeout=aiohttp.ClientTimeout(total=10)) as ws:
await ws.send_json(msg)
deadline = time.time() + wait_sec
while time.time() < deadline:
in_msg = await ws.receive(timeout=wait_sec)
if in_msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(in_msg.data)
except Exception:
continue
if data.get("requestId") == req_id or data.get("id") == req_id:
return data
elif in_msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
return {"status": "timeout"}
async def do_list(args) -> int:
stations = await discover(args.timeout)
for s in stations:
print(f"{s.name}\tdevice_id={s.device_id}\tplatform={s.platform}\t{s.host}:{s.port}")
return 0
async def do_stations_cloud(args) -> int:
oauth = args.token or os.environ.get("YANDEX_TOKEN", "").strip()
if not oauth:
raise RuntimeError("No token. Use --token or env YANDEX_TOKEN")
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"OAuth {oauth}"}
async with session.get(YANDEX_INFO_URL, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as r:
data = await r.json(content_type=None)
rooms = {}
for rm in (data.get("rooms") or []):
rid = str(rm.get("id") or "")
if rid:
rooms[rid] = str(rm.get("name") or "")
out = []
for d in (data.get("devices") or []):
tp = str(d.get("type") or "").lower()
if "speaker" not in tp and "smart_speaker" not in tp:
continue
rid = str(d.get("room") or "")
out.append({
"name": str(d.get("name") or ""),
"id": str(d.get("id") or ""),
"room": rooms.get(rid, ""),
"model": str(d.get("model") or d.get("type") or ""),
})
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
class StationConn:
def __init__(self, station: Station, oauth_token: str, wait_sec: float):
self.station = station
self.oauth_token = oauth_token
self.wait_sec = wait_sec
self._session: Optional[aiohttp.ClientSession] = None
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._conv_token: str = ""
self._conv_exp_ts: float = 0.0
self._lock = asyncio.Lock()
self._playing: Optional[int] = None
self._volume: Optional[int] = None
self._last_probe_ts: float = 0.0
@property
def playing(self) -> Optional[int]:
if self._playing is None:
return None
return 1 if self._playing else 0
@property
def volume(self) -> Optional[int]:
if self._volume is None:
return None
v = int(self._volume)
if v < 0:
return 0
if v > 100:
return 100
return v
@staticmethod
def _to_playing_flag(value: Any) -> Optional[int]:
if isinstance(value, bool):
return 1 if value else 0
if isinstance(value, (int, float)):
v = int(round(float(value)))
if v == 2:
return 1
if v in (0, 1):
return v
if isinstance(value, str):
low = value.strip().lower()
if low in {"playing", "play", "started", "start", "on", "true", "yes"}:
return 1
if low in {"paused", "pause", "stopped", "stop", "idle", "off", "false", "no"}:
return 0
try:
vv = int(float(low))
if vv == 2:
return 1
if vv in (0, 1):
return vv
except Exception:
return None
return None
@classmethod
def _infer_playing(cls, obj: Any) -> Optional[int]:
if isinstance(obj, dict):
for k in ("playing", "isPlaying", "playState", "playerState", "state"):
if k in obj:
out = cls._to_playing_flag(obj.get(k))
if out is not None:
return out
for v in obj.values():
out = cls._infer_playing(v)
if out is not None:
return out
elif isinstance(obj, list):
for v in obj:
out = cls._infer_playing(v)
if out is not None:
return out
return None
@staticmethod
def _to_volume_percent(value: Any) -> Optional[int]:
if isinstance(value, bool):
return 100 if value else 0
if isinstance(value, (int, float)):
v = float(value)
if 0.0 <= v <= 1.0:
v = v * 100.0
iv = int(round(v))
if iv < 0:
iv = 0
if iv > 100:
iv = 100
return iv
if isinstance(value, str):
s = value.strip().replace(",", ".")
if not s:
return None
try:
return StationConn._to_volume_percent(float(s))
except Exception:
return None
return None
@classmethod
def _infer_volume(cls, obj: Any) -> Optional[int]:
if isinstance(obj, dict):
for k in ("volume", "currentVolume", "volumeLevel", "level"):
if k in obj:
out = cls._to_volume_percent(obj.get(k))
if out is not None:
return out
for v in obj.values():
out = cls._infer_volume(v)
if out is not None:
return out
elif isinstance(obj, list):
for v in obj:
out = cls._infer_volume(v)
if out is not None:
return out
return None
async def start(self):
if self._session is None:
self._session = aiohttp.ClientSession()
await self._ensure_ws()
async def close(self):
try:
if self._ws is not None:
await self._ws.close()
except Exception:
pass
self._ws = None
try:
if self._session is not None:
await self._session.close()
except Exception:
pass
self._session = None
async def _ensure_conv_token(self):
now = time.time()
if self._conv_token and self._conv_exp_ts > now:
return
assert self._session is not None
self._conv_token = await get_conversation_token(self._session, self.oauth_token, self.station)
self._conv_exp_ts = now + 60.0
async def _ensure_ws(self):
assert self._session is not None
await self._ensure_conv_token()
if self._ws is not None and not self._ws.closed:
return
url = f"wss://{self.station.host}:{self.station.port}"
self._ws = await self._session.ws_connect(
url,
ssl=_ssl_ctx(),
heartbeat=55,
timeout=aiohttp.ClientTimeout(total=10),
)
async def send(self, payload: dict) -> dict:
async with self._lock:
await self._ensure_ws()
req_id = str(uuid.uuid4())
cmd = str(payload.get("command") or "").strip().lower()
sent_volume = None
if cmd == "setvolume":
sent_volume = self._to_volume_percent(payload.get("volume"))
if sent_volume is not None:
# Optimistic update: status endpoint can immediately expose requested level.
self._volume = sent_volume
msg = {
"conversationToken": self._conv_token,
"id": req_id,
"payload": payload,
"sentTime": int(round(time.time() * 1000)),
}
try:
assert self._ws is not None
await self._ws.send_json(msg)
except Exception:
try:
if self._ws is not None:
await self._ws.close()
except Exception:
pass
self._ws = None
await self._ensure_ws()
assert self._ws is not None
await self._ws.send_json(msg)
deadline = time.time() + self.wait_sec
while time.time() < deadline:
try:
in_msg = await self._ws.receive(timeout=self.wait_sec)
except Exception:
break
if in_msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(in_msg.data)
except Exception:
continue
if data.get("requestId") == req_id or data.get("id") == req_id:
inferred = self._infer_playing(data)
if inferred is not None:
self._playing = inferred
elif cmd == "play":
self._playing = 1
elif cmd in {"pause", "stop"}:
self._playing = 0
inferred_volume = self._infer_volume(data)
if inferred_volume is not None:
self._volume = inferred_volume
elif sent_volume is not None:
self._volume = sent_volume
return data
elif in_msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
if sent_volume is not None:
self._volume = sent_volume
return {"status": "timeout", "id": req_id}
async def probe_state(self) -> None:
now = time.time()
if now - self._last_probe_ts < 5.0:
return
self._last_probe_ts = now
candidates = [
{"command": "getState"},
{"command": "getStatus"},
{"command": "status"},
{"command": "playerState"},
]
for payload in candidates:
try:
data = await self.send(payload)
except Exception:
continue
if self._infer_playing(data) is not None or self._infer_volume(data) is not None:
break
async def serve_daemon(args) -> int:
oauth = args.token or os.environ.get("YANDEX_TOKEN", "").strip()
if not oauth:
raise RuntimeError("No token. Use --token or env YANDEX_TOKEN")
stations = await discover(args.timeout)
selected: List[Station] = []
if args.stations:
for sel in args.stations.split(","):
selected.append(pick_station(stations, sel.strip()))
else:
selected = stations
if not selected:
raise RuntimeError("No stations found")
conns: Dict[str, StationConn] = {}
for st in selected:
conns[st.device_id] = StationConn(st, oauth, args.wait)
await conns[st.device_id].start()
async def resolve_conn(station_sel: str) -> Optional[StationConn]:
station_sel = (station_sel or "").strip()
if not station_sel:
return None
conn = conns.get(station_sel)
if conn is not None:
return conn
low = station_sel.lower()
for c in conns.values():
if c.station.device_id == station_sel or low in (c.station.name or "").lower():
return c
if c.station.host == station_sel or f"{c.station.host}:{c.station.port}" == station_sel:
return c
return None
async def execute_action(req: Dict[str, Any]) -> Dict[str, Any]:
station_sel = str(req.get("station") or "").strip()
if not station_sel:
return {"ok": False, "error": "station is required"}
action = str(req.get("action") or "").strip().lower()
conn = await resolve_conn(station_sel)
if conn is None:
return {"ok": False, "error": "station not found"}
try:
if action == "tts":
text = str(req.get("text") or "").strip()
if not text:
raise RuntimeError("text is required")
volume = req.get("volume")
if volume is not None and str(volume).strip() != "":
vol_payload = {"command": "setVolume", "volume": _volume01_from_0_100(float(volume))}
await conn.send(vol_payload)
payload = {
"command": "serverAction",
"serverActionEventPayload": {
"type": "server_action",
"name": "update_form",
"payload": {
"form_update": {
"name": "personal_assistant.scenarios.repeat_after_me",
"slots": [{"type": "string", "name": "request", "value": text}],
},
"resubmit": True,
},
},
}
elif action == "command":
text = str(req.get("text") or "").strip()
if not text:
raise RuntimeError("text is required")
payload = {"command": "sendText", "text": text}
elif action == "player":
cmd = str(req.get("cmd") or "").strip().lower()
if cmd not in {"play", "pause", "stop", "next", "prev"}:
raise RuntimeError("cmd must be play|pause|stop|next|prev")
payload = {"command": cmd}
elif action == "volume":
lvl = float(req.get("level"))
payload = {"command": "setVolume", "volume": _volume01_from_0_100(lvl)}
elif action == "raw":
payload = req.get("payload")
if isinstance(payload, list):
if not payload:
raise RuntimeError("payload list is empty")
results = []
for idx, p in enumerate(payload):
if not isinstance(p, dict):
raise RuntimeError(f"payload[{idx}] must be object")
results.append(await conn.send(p))
return {"ok": True, "result": results}
if not isinstance(payload, dict):
raise RuntimeError("payload must be object or array of objects")
else:
raise RuntimeError("action must be tts|command|player|volume|raw")
result = await conn.send(payload)
return {"ok": True, "result": result}
except Exception as e:
return {"ok": False, "error": str(e)}
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
try:
line = await reader.readline()
if not line:
writer.close()
return
req = json.loads(line.decode("utf-8", errors="ignore"))
except Exception as e:
writer.write((json.dumps({"ok": False, "error": f"bad request: {e}"}, ensure_ascii=False) + "\n").encode("utf-8"))
await writer.drain()
writer.close()
return
resp = await execute_action(req)
writer.write((json.dumps(resp, ensure_ascii=False) + "\n").encode("utf-8"))
await writer.drain()
writer.close()
server = await asyncio.start_server(handle_client, host=args.host, port=args.port)
addrs = ", ".join(str(sock.getsockname()) for sock in server.sockets or [])
print(f"tcp server listening on {addrs}")
app = web.Application()
async def http_exec(request: web.Request) -> web.Response:
data: Dict[str, Any] = {}
if request.method == "POST":
if request.content_type and "application/json" in request.content_type:
body = await request.json()
if isinstance(body, dict):
data.update(body)
else:
body = await request.post()
data.update({k: v for k, v in body.items()})
data.update({k: v for k, v in request.query.items()})
if "payload" in data and isinstance(data["payload"], str):
payload_raw = str(data["payload"]).strip()
if payload_raw:
try:
data["payload"] = json.loads(payload_raw)
except Exception:
return web.json_response({"ok": False, "error": "bad payload json"}, status=400)
result = await execute_action(data)
return web.json_response(result, status=200 if result.get("ok") else 422)
async def http_status(request: web.Request) -> web.Response:
station_sel = str(request.query.get("station") or "").strip()
conn = await resolve_conn(station_sel)
if conn is None:
return web.json_response({"ok": False, "error": "station not found"}, status=404)
# Keep persistent WS connection: try to restore it on each status poll.
try:
await conn.start()
except Exception:
pass
try:
await conn.probe_state()
except Exception:
pass
running = 1 if (conn._ws is not None and not conn._ws.closed) else 0
return web.json_response({
"ok": 1,
"running": running,
"playing": conn.playing,
"volume": conn.volume,
"station": conn.station.device_id,
})
async def http_health(_request: web.Request) -> web.Response:
return web.json_response({"ok": 1})
app.router.add_get("/health", http_health)
app.router.add_get("/api/status", http_status)
app.router.add_get("/api/exec", http_exec)
app.router.add_post("/api/exec", http_exec)
http_runner = web.AppRunner(app)
await http_runner.setup()
http_site = web.TCPSite(http_runner, host=args.http_host, port=args.http_port)
await http_site.start()
print(f"http server listening on {(args.http_host, args.http_port)}")
try:
async with server:
await server.serve_forever()
finally:
try:
await http_runner.cleanup()
except Exception:
pass
for c in conns.values():
await c.close()
async def client_call(args) -> int:
req = {"station": args.station, "action": args.action}
if args.action in {"tts", "command"}:
req["text"] = args.text
elif args.action == "player":
req["cmd"] = args.cmd
elif args.action == "volume":
req["level"] = float(args.level)
elif args.action == "raw":
req["payload"] = json.loads(args.payload)
reader, writer = await asyncio.open_connection(args.host, args.port)
writer.write((json.dumps(req, ensure_ascii=False) + "\n").encode("utf-8"))
await writer.drain()
line = await reader.readline()
writer.close()
if not line:
print(json.dumps({"ok": False, "error": "no response"}, ensure_ascii=False))
return 2
print(line.decode("utf-8", errors="ignore").rstrip("\n"))
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="yastation.py", description="Yandex station helper")
p.add_argument("--timeout", type=float, default=2.5)
p.add_argument("--token", type=str, default="")
p.add_argument("--wait", type=float, default=5.0)
sub = p.add_subparsers(dest="action", required=True)
sp = sub.add_parser("list")
sp.set_defaults(fn=do_list)
sp = sub.add_parser("stations-cloud")
sp.set_defaults(fn=do_stations_cloud)
sp = sub.add_parser("serve")
sp.add_argument("--host", default="127.0.0.1")
sp.add_argument("--port", type=int, default=9123)
sp.add_argument("--http-host", default="0.0.0.0")
sp.add_argument("--http-port", type=int, default=9124)
sp.add_argument("--stations", default="")
sp.set_defaults(fn=serve_daemon)
sp = sub.add_parser("client")
sp.add_argument("--host", default="127.0.0.1")
sp.add_argument("--port", type=int, default=9123)
sp.add_argument("--station", required=True)
sp.add_argument("action", help="tts|command|player|volume|raw")
sp.add_argument("--text", default="")
sp.add_argument("--cmd", default="")
sp.add_argument("--level", default="0")
sp.add_argument("--payload", default="{}")
sp.set_defaults(fn=client_call)
return p
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return asyncio.run(args.fn(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
raise SystemExit(main())