Files
hisence-mac-app/bridge.py
T
ahmed cc2018aae7
build / sanity (push) Waiting to run
build / macos-build (push) Waiting to run
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
2026-10-13 18:20:11 +03:00

167 lines
4.7 KiB
Python

#!/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