HisenseBar v1.0.0: native menu-bar remote for Hisense VIDAA TVs
- SwiftUI popover: apps grid w/ live search, sources, glass remote, gradient volume slider, animations - Persistent python protocol bridge (vidaa-control, MQTT/TLS :36669) - Wake-on-LAN power-on, one-time PIN pairing with token auto-refresh - MCP server for AI agents (11 tv_* tools) + live test script - Gitea Actions CI workflow
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persistent Vidaa TV bridge: line-JSON commands on stdin, line-JSON responses/events on stdout.
|
||||
|
||||
Usage: bridge.py <host> <mac> <storage_path>
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import os
|
||||
VENV_SITE = os.environ.get("VIDAA_SITE") or str(Path(__file__).resolve().parent.parent / ".venv/lib/python3.9/site-packages")
|
||||
if VENV_SITE not in sys.path:
|
||||
sys.path.insert(0, VENV_SITE)
|
||||
|
||||
from vidaa import AsyncVidaaTV
|
||||
from vidaa.config.storage import TokenStorage
|
||||
|
||||
HOST = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.146"
|
||||
MAC = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
STORE = sys.argv[3] if len(sys.argv) > 3 else str(Path.home() / "Library/Application Support/HisenseBar/tokens.json")
|
||||
|
||||
tv: Optional[AsyncVidaaTV] = None
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
out_lock = threading.Lock()
|
||||
pending = 0
|
||||
eof = False
|
||||
|
||||
|
||||
def emit(obj: dict):
|
||||
with out_lock:
|
||||
print(json.dumps(obj), flush=True)
|
||||
|
||||
|
||||
def wake():
|
||||
addr = MAC.replace(":", "").replace("-", "")
|
||||
if len(addr) != 12:
|
||||
return False
|
||||
pkt = b"\xff" * 6 + bytes.fromhex(addr) * 16
|
||||
for bcast in ("255.255.255.255", ".".join(HOST.split(".")[:3]) + ".255"):
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
s.sendto(pkt, (bcast, 9))
|
||||
s.close()
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
async def get_tv() -> AsyncVidaaTV:
|
||||
global tv
|
||||
if tv is None:
|
||||
storage = TokenStorage(Path(STORE))
|
||||
tv = AsyncVidaaTV(
|
||||
HOST,
|
||||
mac_address=MAC or None,
|
||||
use_dynamic_auth=bool(MAC),
|
||||
storage=storage,
|
||||
on_state_change=lambda d: emit({"event": "state", "data": d}),
|
||||
)
|
||||
return tv
|
||||
|
||||
|
||||
async def handle(c: dict) -> dict:
|
||||
cmd = c.get("cmd")
|
||||
t = await get_tv()
|
||||
|
||||
if cmd == "connect":
|
||||
ok = await t.async_connect(timeout=8, auto_auth=True)
|
||||
return {"ok": ok}
|
||||
if cmd == "pair_start":
|
||||
ok = await t.async_start_pairing()
|
||||
return {"ok": ok}
|
||||
if cmd == "pair_auth":
|
||||
ok = await t.async_authenticate(str(c["pin"]), timeout=15)
|
||||
return {"ok": ok}
|
||||
if cmd == "key":
|
||||
await t.async_send_key(c["key"])
|
||||
return {"ok": True}
|
||||
if cmd == "volume":
|
||||
v = await t.async_get_volume()
|
||||
return {"ok": v is not None, "volume": v}
|
||||
if cmd == "set_volume":
|
||||
await t.async_set_volume(int(c["value"]))
|
||||
return {"ok": True}
|
||||
if cmd == "mute":
|
||||
await t.async_mute()
|
||||
return {"ok": True}
|
||||
if cmd == "apps":
|
||||
apps = await t.async_get_apps()
|
||||
return {"ok": apps is not None, "apps": apps or []}
|
||||
if cmd == "launch":
|
||||
await t.async_launch_app(c["app"])
|
||||
return {"ok": True}
|
||||
if cmd == "sources":
|
||||
srcs = await t.async_get_sources()
|
||||
return {"ok": srcs is not None, "sources": srcs or []}
|
||||
if cmd == "set_source":
|
||||
payload = {"sourceid": str(c["source"])}
|
||||
if c.get("name"):
|
||||
payload["sourcename"] = str(c["name"])
|
||||
|
||||
def _pub():
|
||||
cl = t._ensure_client()
|
||||
from vidaa.topics import get_topic, TOPIC_SET_SOURCE
|
||||
return cl._publish(get_topic(TOPIC_SET_SOURCE, cl.client_id), payload)
|
||||
|
||||
await t._run_in_executor(_pub)
|
||||
return {"ok": True}
|
||||
if cmd == "state":
|
||||
st = await t.async_get_state()
|
||||
return {"ok": st is not None, "state": st}
|
||||
if cmd == "power_off":
|
||||
await t.async_power_off()
|
||||
return {"ok": True}
|
||||
if cmd == "wake":
|
||||
return {"ok": wake()}
|
||||
return {"ok": False, "error": f"unknown cmd {cmd}"}
|
||||
|
||||
|
||||
def stdin_reader():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
rid = msg.get("id")
|
||||
global pending
|
||||
pending += 1
|
||||
fut = asyncio.run_coroutine_threadsafe(handle(msg), loop)
|
||||
try:
|
||||
res = fut.result(timeout=45)
|
||||
except Exception as e:
|
||||
res = {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
res["id"] = rid
|
||||
emit(res)
|
||||
pending -= 1
|
||||
global eof
|
||||
eof = True
|
||||
if pending == 0:
|
||||
import os
|
||||
os._exit(0)
|
||||
|
||||
|
||||
async def main():
|
||||
global loop
|
||||
loop = asyncio.get_running_loop()
|
||||
threading.Thread(target=stdin_reader, daemon=True).start()
|
||||
emit({"event": "ready"})
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Reference in New Issue
Block a user