HisenseBar v1.0.0: native menu-bar remote for Hisense VIDAA TVs
build / sanity (push) Waiting to run
build / macos-build (push) Waiting to run

- 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:
2026-10-13 18:20:11 +03:00
commit cc2018aae7
12 changed files with 1699 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
name: build
on: [push, workflow_dispatch]
jobs:
sanity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python bridge syntax check
run: |
python3 -m py_compile bridge.py mcp_tv_server.py mcp_test.py
echo "bridge OK"
- name: Scripts shell syntax check
run: bash -n make_app.sh
- name: Swift sources present
run: test -f Package.swift && test -d Sources/HisenseBar
# Runs only on a macOS runner with Xcode; skipped otherwise.
macos-build:
runs-on: macos-latest
if: false # enable when a macOS runner is registered
steps:
- uses: actions/checkout@v4
- name: Build app bundle
run: bash make_app.sh
- uses: actions/upload-artifact@v4
with:
name: HisenseBar.app
path: HisenseBar.app
+12
View File
@@ -0,0 +1,12 @@
.build/
HisenseBar.app/
*.zip
*.icns
tokens*.json
.DS_Store
.venv/
# local agent tooling
.agents/
.claude/
skills-lock.json
.cache/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ahmed
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "HisenseBar",
platforms: [.macOS(.v14)],
targets: [
.executableTarget(
name: "HisenseBar",
path: "Sources/HisenseBar"
)
]
)
+71
View File
@@ -0,0 +1,71 @@
# HisenseBar
A native macOS menu-bar app that controls Hisense VIDAA smart TVs over the local network — volume, apps, input sources, remote keys, and power, all from the menu bar.
![HisenseBar](docs/icon.png)
## Features
- **Volume** — live slider with mute (changes are pushed to the TV as you drag)
- **Apps** — full app grid with icons and local search; click to launch
- **Sources** — switch inputs (TV, HDMI1–3, AV…) with friendly device names ("PlayStation 5")
- **Remote** — d-pad with hold-to-repeat, OK/back/home/menu, transport keys, number pad
- **Power** — off via remote key, on via Wake-on-LAN (MAC auto-detected)
- **Pairing** — one-time PIN flow straight from the popover; token stored in `~/Library/Application Support/HisenseBar/tokens.json` and auto-refreshed
## Requirements
- macOS 14+
- Hisense VIDAA TV on the same network (MQTT protocol, port 36669)
- Python 3.9+ on this Mac for the protocol bridge (see Architecture)
## Install
1. Download `HisenseBar.app.zip` from [Releases](https://gitea.anwarpy.ai/ahmed/hisence-mac-app/releases).
2. Unzip and drag `HisenseBar.app` to `/Applications`.
3. First launch: right-click → **Open** (the app is not notarized), then allow **Local Network** access when macOS asks.
4. Click the **TV icon** in the menu bar → enter the PIN shown on your TV once.
## Build from source
```bash
git clone https://gitea.anwarpy.ai/ahmed/hisence-mac-app.git
cd hisence-mac-app
# create a python venv with the vidaa-control protocol library, then:
bash make_app.sh
```
`make_app.sh` builds the Swift package, assembles `HisenseBar.app`, and bundles the bridge + icon.
## Architecture
```
SwiftUI menu-bar app (BridgeTV actor)
│ line-JSON over stdio
▼
bridge.py — persistent protocol bridge (vidaa-control implementation)
│ MQTT 3.1.1 over TLS :36669
▼
Hisense VIDAA TV
```
The UI is pure native SwiftUI. All TV protocol work (pairing, tokens, MQTT) happens in the Python bridge process, which stays connected for instant commands. The TV's TLS stack rejects Apple's Network.framework TLS, but accepts OpenSSL — the bridge sidesteps that entirely.
## Troubleshooting
| Problem | Fix |
|---|---|
| Nothing happens / no PIN | System Settings → Privacy & Security → **Local Network** → enable HisenseBar |
| macOS blocks launch | Right-click the app → Open (unsigned build) |
| TV shows "fake sleep", commands half-work | Press any key once — the screen wakes, everything responds |
| Pairing expired | Menu bar → gear → Pair TV again |
## Roadmap
- [x] Design refresh: animations, custom slider, glass remote buttons, app icon
- [ ] Hermes MCP server (`mcp_tv_server.py` — in progress, 11 `tv_*` tools)
- [ ] Notarized builds
## License
MIT — see [LICENSE](LICENSE).
+134
View File
@@ -0,0 +1,134 @@
import Foundation
/// JSON dict from the bridge; immutable after parse, safe to pass across isolation.
struct BridgeReply: @unchecked Sendable {
let dict: [String: Any]
subscript(_ key: String) -> Any? { dict[key] }
}
/// Actor owning the persistent python bridge process (line-JSON over stdio).
actor BridgeTV {
enum BridgeError: Error, LocalizedError {
case spawn(String)
case timeout
case closed
var errorDescription: String? {
switch self {
case .spawn(let why): return "bridge start failed: \(why)"
case .timeout: return "bridge timeout"
case .closed: return "bridge closed"
}
}
}
static let shared = BridgeTV()
private var process: Process?
private var nextID = 1
private var waiters: [Int: CheckedContinuation<BridgeReply, Error>] = [:]
private var buffer = Data()
private var eventHandler: (@Sendable (BridgeReply) -> Void)?
private let venvSite = "/Users/ahmedmohammed/Documents/rae/.venv/lib/python3.9/site-packages"
private let python = "/Users/ahmedmohammed/Documents/rae/.venv/bin/python3"
private var scriptURL: URL {
if let bundled = Bundle.main.url(forResource: "bridge", withExtension: "py") { return bundled }
return URL(fileURLWithPath: "/Users/ahmedmohammed/Documents/rae/HisenseBar/bridge.py")
}
func setEventHandler(_ handler: @escaping @Sendable (BridgeReply) -> Void) {
eventHandler = handler
}
private func ensureProcess() throws -> Process {
if let p = process, p.isRunning { return p }
waiters.values.forEach { $0.resume(throwing: BridgeError.closed) }
waiters.removeAll()
buffer.removeAll()
let p = Process()
p.executableURL = URL(fileURLWithPath: python)
p.arguments = [scriptURL.path,
UserDefaults.standard.string(forKey: "hisensebar.ip") ?? "192.168.1.146",
UserDefaults.standard.string(forKey: "hisensebar.mac") ?? "",
NSString(string: "~/Library/Application Support/HisenseBar/tokens.json").expandingTildeInPath]
var env = ProcessInfo.processInfo.environment
env["VIDAA_SITE"] = venvSite
env["PYTHONUNBUFFERED"] = "1"
p.environment = env
let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe()
p.standardInput = inPipe
p.standardOutput = outPipe
p.standardError = errPipe
do { try p.run() } catch { throw BridgeError.spawn("\(error)") }
process = p
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else {
handle.readabilityHandler = nil
Task { await self?.handleClosed() }
return
}
Task { await self?.feed(data) }
}
return p
}
private func feed(_ data: Data) {
buffer.append(data)
while let nl = buffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = buffer.subdata(in: buffer.startIndex..<nl)
buffer.removeSubrange(buffer.startIndex...nl)
guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] else { continue }
let reply = BridgeReply(dict: obj)
if let id = obj["id"] as? Int, let cont = waiters.removeValue(forKey: id) {
cont.resume(returning: reply)
} else if let handler = eventHandler {
handler(reply)
}
}
}
private func handleClosed() {
waiters.values.forEach { $0.resume(throwing: BridgeError.closed) }
waiters.removeAll()
process = nil
}
func request(_ cmd: [String: Any], timeout: TimeInterval = 30) async throws -> BridgeReply {
let p = try ensureProcess()
let id = nextID
nextID += 1
var msg = cmd
msg["id"] = id
let payload = try JSONSerialization.data(withJSONObject: msg) + Data("\n".utf8)
return try await withCheckedThrowingContinuation { cont in
waiters[id] = cont
guard let pipe = p.standardInput as? Pipe else {
waiters.removeValue(forKey: id)
cont.resume(throwing: BridgeError.closed)
return
}
do {
try pipe.fileHandleForWriting.write(contentsOf: payload)
} catch {
waiters.removeValue(forKey: id)
cont.resume(throwing: BridgeError.closed)
}
Task {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
await self.timeoutID(id)
}
}
}
private func timeoutID(_ id: Int) {
if let cont = waiters.removeValue(forKey: id) {
cont.resume(throwing: BridgeError.timeout)
}
}
}
+895
View File
@@ -0,0 +1,895 @@
import SwiftUI
import os
// MARK: - Model
@MainActor @Observable
final class TVModel {
static let shared = TVModel()
private let log = Logger(subsystem: "rae.hisensebar", category: "net")
nonisolated static func diag(_ line: String) {
let path = "/tmp/hisensebar-diag.log"
let text = "\(Date()) \(line)\n"
if let fh = FileHandle(forWritingAtPath: path) { try? fh.seekToEnd(); try? fh.write(Data(text.utf8)); try? fh.close() }
else { try? Data(text.utf8).write(to: URL(fileURLWithPath: path)) }
}
enum Status: Equatable {
case disconnected, connecting, connected
case pairingAwaitingPIN, pairingFailed(String)
}
var status: Status = .disconnected
var volume = 0
var muted = false
var apps: [TVApp] = []
var sources: [TVSource] = []
var nowLine = ""
var settingsIP = UserDefaults.standard.string(forKey: "hisensebar.ip") ?? "192.168.1.146"
var settingsMAC = UserDefaults.standard.string(forKey: "hisensebar.mac") ?? "e0:3e:cb:f4:68:be"
var showSettings = false
var pairPIN = ""
var busy = false
var needsPairing = false
private let tv = BridgeTV.shared
private var volumeDebounce: Task<Void, Never>?
var isConnected: Bool { status == .connected }
private var started = false
init() {
if (UserDefaults.standard.string(forKey: "hisensebar.mac") ?? "").isEmpty {
UserDefaults.standard.set("e0:3e:cb:f4:68:be", forKey: "hisensebar.mac")
}
Task { await start() }
}
func start() async {
guard !started else { return }
started = true
await tv.setEventHandler { [weak self] reply in
Task { @MainActor in self?.handleBridgeEvent(reply) }
}
await connect()
}
private func bridgeDict(_ key: String) -> [String: Any] {
let stored = UserDefaults.standard.string(forKey: key) ?? ""
return stored.isEmpty ? [:] : [key: stored]
}
func connect() async {
status = .connecting
do {
let reply = try await tv.request(["cmd": "connect", "host": settingsIP, "mac": settingsMAC])
guard reply["ok"] as? Bool == true else { throw BridgeError2(msg: reply["error"] as? String ?? "connect failed") }
status = .connected
if let v = try? await getVolume() { volume = v }
await refreshLists()
} catch {
Self.diag("connect failed: \(error)")
status = .disconnected
}
}
struct BridgeError2: LocalizedError { let msg: String; var errorDescription: String? { msg } }
private func handleBridgeEvent(_ reply: BridgeReply) {
guard reply["event"] as? String == "state", let data = reply["data"] as? [String: Any] else { return }
applyBroadcast(data)
}
private func applyBroadcast(_ json: [String: Any]) {
if let vt = (json["volume_type"] as? Int) ?? Int(json["volume_type"] as? String ?? "0") {
let raw = (json["volume_value"] as? Int) ?? Int(json["volume_value"] as? String ?? "") ?? 0
if vt == 0 { volume = raw }
if vt == 2 { muted = (raw == 1) }
}
if let statetype = json["statetype"] as? String {
let app = (json["app_name"] as? String) ?? (json["appname"] as? String) ?? ""
let src = (json["sourcename"] as? String) ?? (json["sourceid"] as? String) ?? ""
var parts: [String] = []
if !app.isEmpty { parts.append(app) }
if !src.isEmpty { parts.append(src) }
nowLine = statetype == "fake_sleep_0" ? "TV asleep" : (parts.isEmpty ? "" : parts.joined(separator: " · "))
}
}
// MARK: Pairing
func startPairing() async {
do {
status = .connecting
_ = try await tv.request(["cmd": "connect", "host": settingsIP, "mac": settingsMAC])
let reply = try await tv.request(["cmd": "pair_start"])
guard reply["ok"] as? Bool == true else { throw BridgeError2(msg: reply["error"] as? String ?? "pair start failed") }
status = .pairingAwaitingPIN
} catch {
Self.diag("pairing connect failed: \(error)")
status = .pairingFailed("Could not reach TV at \(settingsIP): \(error)")
}
}
func submitPIN() async {
guard status == .pairingAwaitingPIN else { return }
do {
let reply = try await tv.request(["cmd": "pair_auth", "pin": pairPIN])
guard reply["ok"] as? Bool == true else { throw BridgeError2(msg: reply["error"] as? String ?? "pairing refused") }
pairPIN = ""
UserDefaults.standard.set(settingsIP, forKey: "hisensebar.ip")
UserDefaults.standard.set(settingsMAC, forKey: "hisensebar.mac")
needsPairing = false
status = .connected
if let v = try? await getVolume() { volume = v }
await refreshLists()
} catch {
status = .pairingFailed("Wrong PIN or TV refused pairing")
}
}
// MARK: Commands
private func getVolume() async throws -> Int? {
let reply = try await tv.request(["cmd": "volume"])
return reply["volume"] as? Int
}
func setVolumeLive(_ value: Int) {
volume = value
volumeDebounce?.cancel()
volumeDebounce = Task { [tv] in
try? await Task.sleep(nanoseconds: 150_000_000)
guard !Task.isCancelled else { return }
_ = try? await tv.request(["cmd": "set_volume", "value": value])
}
}
func toggleMute() async {
muted.toggle()
_ = try? await tv.request(["cmd": "key", "key": "KEY_MUTE"])
}
func launch(_ app: TVApp) async {
var appDict: [String: Any] = ["name": app.name]
if let id = app.appID { appDict["appId"] = id }
if let url = app.url { appDict["url"] = url }
_ = try? await tv.request(["cmd": "launch", "app": appDict])
}
func selectSource(_ source: TVSource) async {
_ = try? await tv.request(["cmd": "set_source", "source": source.sourceID, "name": source.rawName])
}
func sendKey(_ key: String) async {
_ = try? await tv.request(["cmd": "key", "key": key])
}
func powerOff() async { await sendKey("KEY_POWER") }
func powerOn() async {
guard !settingsMAC.isEmpty else { return }
_ = try? await tv.request(["cmd": "wake", "mac": settingsMAC])
try? await Task.sleep(nanoseconds: 6_000_000_000)
await connect()
}
func refreshLists() async {
if let reply = try? await tv.request(["cmd": "apps"]),
let list = reply["apps"] as? [[String: Any]] {
apps = list.compactMap(TVApp.init(dict:))
}
if let reply = try? await tv.request(["cmd": "sources"]),
let list = reply["sources"] as? [[String: Any]] {
sources = list.compactMap(TVSource.init(dict:))
}
}
func rediscover() async {
busy = true
if settingsMAC.isEmpty, let arpMAC = arpMAC(for: settingsIP) { settingsMAC = arpMAC }
busy = false
}
private func arpMAC(for ip: String) -> String? {
// lazy: read the ARP table instead of raw socket ARP; filled after first TV contact
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/sbin/arp")
proc.arguments = ["-a"]
let out = Pipe()
proc.standardOutput = out
do { try proc.run() } catch { return nil }
let data = out.fileHandleForReading.readDataToEndOfFile()
proc.waitUntilExit()
guard let text = String(data: data, encoding: .utf8) else { return nil }
for line in text.split(separator: "\n") where line.contains(ip) {
let parts = line.split(separator: " ")
if let mac = parts.last(where: { $0.contains(":") && $0.count == 17 }) {
return String(mac).uppercased()
}
}
return nil
}
}
// MARK: - TV data
struct TVApp: Identifiable, Hashable {
let name: String
let appID: String?
let url: String?
let iconURL: URL?
var id: String { appID ?? name }
init?(dict: [String: Any]) {
guard let name = (dict["name"] as? String) ?? (dict["appname"] as? String) else { return nil }
self.name = name
self.appID = dict["appId"] as? String
self.url = dict["url"] as? String
if let icon = dict["httpIcon"] as? String, icon.contains("http") {
let cleaned = icon.replacingOccurrences(of: "data:image/png;base64,iconDownload", with: "")
self.iconURL = URL(string: cleaned)
} else {
self.iconURL = nil
}
}
}
struct TVSource: Identifiable, Hashable {
let sourceID: String
let rawName: String
let name: String
var id: String { sourceID }
init?(dict: [String: Any]) {
guard let sid = dict["sourceid"] as? String else { return nil }
self.sourceID = sid
self.rawName = (dict["sourcename"] as? String) ?? sid
let friendly = dict["displayname2"] as? String ?? ""
let display = dict["displayname"] as? String ?? sid
self.name = friendly.isEmpty ? display : "\(display) · \(friendly)"
}
}
// MARK: - App
@main
struct HisenseBarApp: App {
init() {
Task { @MainActor in await TVModel.shared.start() }
}
var body: some Scene {
MenuBarExtra("Hisense TV", systemImage: "tv") {
TVPopover()
.environment(TVModel.shared)
.frame(width: 330)
}
.menuBarExtraStyle(.window)
}
}
// MARK: - Design system
struct PressableButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.93 : 1)
.animation(.spring(response: 0.28, dampingFraction: 0.55), value: configuration.isPressed)
}
}
extension View {
func pressable() -> some View { buttonStyle(PressableButtonStyle()) }
}
/// Circular glass key used across the remote.
struct GlassKey: View {
let label: String
var icon: String?
var tint: Color = .primary
var action: () -> Void
@State private var pressed = false
var body: some View {
Button(action: action) {
Group {
if let icon {
Image(systemName: icon).font(.system(size: 15, weight: .medium))
} else {
Text(label).font(.system(size: 12, weight: .semibold))
}
}
.foregroundStyle(tint)
.frame(width: 46, height: 46)
.background(Circle().fill(.ultraThinMaterial))
.background(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5))
.scaleEffect(pressed ? 0.88 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.5), value: pressed)
}
.buttonStyle(.plain)
.simultaneousGesture(DragGesture(minimumDistance: 0)
.onChanged { _ in pressed = true }
.onEnded { _ in pressed = false })
}
}
/// Gradient capsule volume slider.
struct VolumeSlider: View {
@Binding var value: Double
@State private var dragging = false
var body: some View {
GeometryReader { geo in
let fraction = max(0.0, min(1.0, value / 100.0))
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(0.12)).frame(height: 8)
Capsule()
.fill(LinearGradient(colors: [.blue, .cyan], startPoint: .leading, endPoint: .trailing))
.frame(width: max(8, geo.size.width * fraction), height: 8)
Circle()
.fill(Color.white)
.frame(width: dragging ? 20 : 16, height: dragging ? 20 : 16)
.shadow(color: .blue.opacity(0.45), radius: dragging ? 6 : 3, y: 1)
.offset(x: max(0, geo.size.width * fraction - (dragging ? 10 : 8)))
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: dragging)
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onChanged { g in
dragging = true
value = max(0, min(100, g.location.x / geo.size.width * 100.0))
}.onEnded { _ in dragging = false })
}
.frame(height: 24)
}
}
/// Animated three-dot waiting indicator.
struct PulsingDots: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var on = false
var body: some View {
HStack(spacing: 5) {
ForEach(0..<3, id: \.self) { i in
Circle().fill(Color.accentColor)
.frame(width: 6, height: 6)
.opacity(reduceMotion ? 0.8 : (on ? 1 : 0.25))
.animation(reduceMotion ? nil : .easeInOut(duration: 0.6).repeatForever(autoreverses: true).delay(Double(i) * 0.18), value: on)
}
}
.onAppear { on = true }
}
}
// MARK: - Popover
enum TVTab: String, CaseIterable {
case apps = "Apps", sources = "Sources", remote = "Remote"
var icon: String {
switch self {
case .apps: "square.grid.2x2"
case .sources: "switch.2"
case .remote: "appletv.remote"
}
}
}
struct TVPopover: View {
@Environment(TVModel.self) private var model
@State private var tab: TVTab = .apps
@State private var appeared = false
var body: some View {
VStack(spacing: 12) {
HeaderView()
if model.needsPairing {
PairingSection()
} else {
ControlsView()
TabSelector(tab: $tab)
Group {
switch tab {
case .apps: AppsTabView()
case .sources: SourcesTabView()
case .remote: RemoteTabView()
}
}
.frame(minHeight: 250)
.transition(.opacity.combined(with: .move(edge: .trailing)))
.id(tab)
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: tab)
}
FooterView()
}
.padding(14)
.fontDesign(.rounded)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 8)
.onAppear {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { appeared = true }
}
}
}
struct TabSelector: View {
@Binding var tab: TVTab
@Namespace private var ns
var body: some View {
HStack(spacing: 2) {
ForEach(TVTab.allCases, id: \.self) { t in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { tab = t }
} label: {
Label(t.rawValue, systemImage: t.icon)
.font(.caption.weight(.semibold))
.foregroundStyle(tab == t ? Color.white : Color.secondary)
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.background {
if tab == t {
Capsule()
.fill(Color.accentColor.gradient)
.matchedGeometryEffect(id: "tabcap", in: ns)
}
}
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Color.primary.opacity(0.07)))
}
}
struct HeaderView: View {
@Environment(TVModel.self) private var model
@State private var pulse = false
var body: some View {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 9)
.fill(LinearGradient(colors: [.blue, .cyan], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 34, height: 34)
Image(systemName: "tv")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 1) {
Text("Living Room TV").font(.headline)
HStack(spacing: 5) {
if model.status == .connecting {
PulsingDots()
} else {
Circle()
.fill(statusColor)
.frame(width: 7, height: 7)
.scaleEffect(model.status == .connecting ? 1 : 1)
}
Text(statusText).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
}
.padding(.vertical, 2)
}
private var statusColor: Color {
switch model.status {
case .connected: .green
case .connecting: .orange
case .disconnected: .secondary.opacity(0.6)
case .pairingAwaitingPIN: .yellow
case .pairingFailed: .red
}
}
private var statusText: String {
switch model.status {
case .connected: return model.nowLine.isEmpty ? "Connected" : model.nowLine
case .connecting: return "Connecting…"
case .disconnected: return "Disconnected"
case .pairingAwaitingPIN: return "Enter PIN from TV"
case .pairingFailed(let why): return why
}
}
}
struct ControlsView: View {
@Environment(TVModel.self) private var model
var body: some View {
VStack(spacing: 8) {
HStack(spacing: 10) {
GlassKey(label: "", icon: "power", tint: .red) { Task { await model.powerOff() } }
.help("Power off")
VolumeSlider(value: Binding(
get: { Double(model.volume) },
set: { model.setVolumeLive(Int($0.rounded())) }
))
GlassKey(label: "", icon: model.muted ? "speaker.slash.fill" : "speaker.wave.2") {
Task { await model.toggleMute() }
}
.help("Mute")
}
}
}
}
struct FooterView: View {
@Environment(TVModel.self) private var model
var body: some View {
@Bindable var model = model
HStack {
Text("VIDAA").font(.caption2.weight(.medium)).foregroundStyle(.tertiary)
Spacer()
Button {
model.showSettings = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.pressable()
.help("Settings")
}
.sheet(isPresented: $model.showSettings) { SettingsView() }
}
}
struct AppsTabView: View {
@Environment(TVModel.self) private var model
@State private var search = ""
private var filtered: [TVApp] {
search.isEmpty ? model.apps : model.apps.filter { $0.name.localizedCaseInsensitiveContains(search) }
}
var body: some View {
VStack(spacing: 8) {
TextField("Search apps…", text: $search)
.textFieldStyle(.roundedBorder)
.fontDesign(.rounded)
.onSubmit {
if let top = filtered.first { Task { await model.launch(top) } }
}
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 68), spacing: 8)], spacing: 10) {
ForEach(Array(filtered.enumerated()), id: \.element.id) { idx, app in
Button {
Task { await model.launch(app) }
} label: {
AppTile(app: app)
}
.buttonStyle(.plain)
.opacity(1.0)
.scaleEffect(1.0)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
}
.padding(.horizontal, 2)
}
}
}
}
struct AppTile: View {
let app: TVApp
@State private var hover = false
@State private var pressed = false
var body: some View {
VStack(spacing: 5) {
icon
.frame(height: 46)
.frame(maxWidth: .infinity)
Text(app.name)
.font(.caption2)
.lineLimit(1)
.foregroundStyle(.primary)
}
.padding(6)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.primary.opacity(hover ? 0.07 : 0.04))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.primary.opacity(hover ? 0.12 : 0), lineWidth: 0.5)
)
.scaleEffect(pressed ? 0.94 : (hover ? 1.04 : 1))
.shadow(color: .black.opacity(hover ? 0.12 : 0), radius: 5, y: 2)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: hover)
.animation(.spring(response: 0.2, dampingFraction: 0.6), value: pressed)
.onHover { hover = $0 }
.simultaneousGesture(DragGesture(minimumDistance: 0)
.onChanged { _ in pressed = true }
.onEnded { _ in pressed = false })
}
@ViewBuilder private var icon: some View {
if let url = app.iconURL {
AsyncImage(url: url) { img in
img.resizable().scaledToFit()
} placeholder: {
ZStack {
RoundedRectangle(cornerRadius: 10).fill(Color.accentColor.opacity(0.12))
Image(systemName: "app.fill").foregroundStyle(.secondary)
}
}
} else {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(LinearGradient(colors: [.accentColor.opacity(0.7), .accentColor.opacity(0.4)], startPoint: .top, endPoint: .bottom))
Text(String(app.name.prefix(2))).font(.callout).bold().foregroundStyle(.white)
}
}
}
}
struct SourcesTabView: View {
@Environment(TVModel.self) private var model
var body: some View {
ScrollView {
VStack(spacing: 6) {
ForEach(model.sources) { source in
SourceRow(source: source) {
Task { await model.selectSource(source) }
}
}
}
.padding(.horizontal, 2)
}
}
}
struct SourceRow: View {
let source: TVSource
let action: () -> Void
@State private var hover = false
@State private var pressed = false
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: iconName)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Color.accentColor)
.frame(width: 28, height: 28)
.background(Circle().fill(Color.accentColor.opacity(0.12)))
VStack(alignment: .leading, spacing: 0) {
Text(source.name).font(.callout.weight(.medium))
if source.rawName != source.name {
Text(source.rawName).font(.caption2).foregroundStyle(.tertiary)
}
}
Spacer()
Image(systemName: "arrow.right.circle")
.foregroundStyle(hover ? Color.accentColor : Color.clear)
}
.padding(.vertical, 7)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.primary.opacity(hover ? 0.06 : 0.03))
)
.scaleEffect(pressed ? 0.98 : 1)
}
.buttonStyle(.plain)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: hover)
.onHover { hover = $0 }
.simultaneousGesture(DragGesture(minimumDistance: 0)
.onChanged { _ in pressed = true }
.onEnded { _ in pressed = false })
}
private var iconName: String {
switch source.sourceID.uppercased() {
case "TV": "tv"
case let s where s.contains("HDMI"): "cable.connector"
case "AVS", "AV": "music.note.tv"
case "MUSICSHARING": "music.note.list"
default: "play.rectangle"
}
}
}
struct RemoteTabView: View {
@Environment(TVModel.self) private var model
var body: some View {
ScrollView {
VStack(spacing: 14) {
DPadCluster(model: model)
HStack(spacing: 10) {
GlassKey(label: "", icon: "backward.end.fill") { Task { await model.sendKey("KEY_REWIND") } }
GlassKey(label: "", icon: "playpause.fill") { Task { await model.sendKey("KEY_PLAY") } }
GlassKey(label: "", icon: "forward.end.fill") { Task { await model.sendKey("KEY_FORWARDS") } }
GlassKey(label: "", icon: "info.circle") { Task { await model.sendKey("KEY_INFO") } }
}
HStack(spacing: 10) {
GlassKey(label: "Back") { Task { await model.sendKey("KEY_RETURNS") } }
GlassKey(label: "Home") { Task { await model.sendKey("KEY_HOME") } }
GlassKey(label: "Menu") { Task { await model.sendKey("KEY_MENU") } }
GlassKey(label: "Exit") { Task { await model.sendKey("KEY_EXIT") } }
}
VStack(spacing: 6) {
HStack(spacing: 6) {
ForEach(1...5, id: \.self) { n in NumKey(n: n, model: model) }
}
HStack(spacing: 6) {
ForEach([6, 7, 8, 9, 0], id: \.self) { n in NumKey(n: n, model: model) }
}
}
}
.padding(4)
}
}
}
struct DPadCluster: View {
let model: TVModel
var body: some View {
VStack(spacing: 8) {
RepeatGlass(key: "KEY_UP", model: model)
HStack(spacing: 8) {
RepeatGlass(key: "KEY_LEFT", model: model)
GlassKey(label: "OK", tint: .accentColor) { Task { await model.sendKey("KEY_OK") } }
RepeatGlass(key: "KEY_RIGHT", model: model)
}
RepeatGlass(key: "KEY_DOWN", model: model)
}
}
}
struct NumKey: View {
let n: Int
let model: TVModel
var body: some View {
GlassKey(label: "\(n)") { Task { await model.sendKey("KEY_\(n)") } }
.frame(maxWidth: .infinity)
}
}
// Hold-to-repeat glass d-pad key
struct RepeatGlass: View {
let key: String
let model: TVModel
@State private var task: Task<Void, Never>?
@State private var pressed = false
var body: some View {
Button {
Task { await model.sendKey(key) }
} label: {
Image(systemName: iconName)
.font(.system(size: 15, weight: .medium))
.frame(width: 46, height: 46)
.background(Circle().fill(.ultraThinMaterial))
.background(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5))
.scaleEffect(pressed ? 0.88 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.5), value: pressed)
}
.buttonStyle(.plain)
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
pressed = pressing
if pressing {
task = Task { @MainActor in
while !Task.isCancelled {
await model.sendKey(key)
try? await Task.sleep(nanoseconds: 180_000_000)
}
}
} else {
task?.cancel()
}
}, perform: {})
.simultaneousGesture(DragGesture(minimumDistance: 0)
.onChanged { _ in pressed = true }
.onEnded { _ in pressed = false })
}
private var iconName: String {
switch key {
case "KEY_UP": "arrowtriangle.up.fill"
case "KEY_DOWN": "arrowtriangle.down.fill"
case "KEY_LEFT": "arrowtriangle.left.fill"
case "KEY_RIGHT": "arrowtriangle.right.fill"
default: "circle"
}
}
}
// MARK: - Settings
struct SettingsView: View {
@Environment(TVModel.self) private var model
@Environment(\.dismiss) private var dismiss
var body: some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: 10) {
Text("TV Connection").font(.headline)
HStack {
Text("IP:").frame(width: 40, alignment: .leading)
TextField("192.168.1.146", text: $model.settingsIP).textFieldStyle(.roundedBorder)
Button(model.busy ? "…" : "Find") { Task { await model.rediscover() } }
}
HStack {
Text("MAC:").frame(width: 40, alignment: .leading)
TextField("e0:3e:cb:f4:68:be", text: $model.settingsMAC).textFieldStyle(.roundedBorder)
}
Text("MAC is used for Wake-on-LAN (power on).").font(.caption).foregroundStyle(.secondary)
Divider()
PairingSection()
HStack {
Spacer()
Button("Save") {
UserDefaults.standard.set(model.settingsIP, forKey: "hisensebar.ip")
UserDefaults.standard.set(model.settingsMAC, forKey: "hisensebar.mac")
dismiss()
Task { await model.connect() }
}
.keyboardShortcut(.defaultAction)
Button("Cancel") { dismiss() }
}
}
.padding(16)
.fontDesign(.rounded)
.frame(width: 340)
}
}
struct PairingSection: View {
@Environment(TVModel.self) private var model
var body: some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: 8) {
Text("Pairing").font(.headline)
switch model.status {
case .pairingAwaitingPIN:
HStack(spacing: 8) {
PulsingDots()
Text("A PIN is on your TV screen — enter it:").font(.caption)
}
HStack {
TextField("PIN", text: $model.pairPIN)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await model.submitPIN() } }
Button("Pair") { Task { await model.submitPIN() } }
.pressable()
}
case .pairingFailed(let why):
Text(why).font(.caption).foregroundStyle(.red)
PairButton()
default:
PairButton()
}
}
}
}
struct PairButton: View {
@Environment(TVModel.self) private var model
var body: some View {
Button {
Task { await model.startPairing() }
} label: {
Label("Pair TV — shows a PIN on the screen", systemImage: "antenna.radiowaves.left.and.right")
}
.pressable()
}
}
+166
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Executable
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Assemble HisenseBar.app from the release build
set -e
cd "$(dirname "$0")"
swift build -c release
APP="HisenseBar.app"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS"
cp .build/release/HisenseBar "$APP/Contents/MacOS/HisenseBar"
mkdir -p "$APP/Contents/Resources"
cp bridge.py "$APP/Contents/Resources/bridge.py"
[ -f /tmp/AppIcon.icns ] && cp /tmp/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
cat > "$APP/Contents/Info.plist" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>HisenseBar</string>
<key>CFBundleIdentifier</key><string>rae.hisensebar</string>
<key>CFBundleName</key><string>HisenseBar</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>LSUIElement</key><true/>
<key>NSLocalNetworkUsageDescription</key><string>HisenseBar connects to your Hisense TV on the local network to control it.</string>
</dict>
</plist>
EOF
echo "Built $APP"
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Live test for mcp_tv_server.py: handshake, tools/list, real TV calls. Exit 0 = pass."""
import json
import subprocess
import sys
SERVER = "/Users/ahmedmohammed/Documents/rae/HisenseBar/mcp_tv_server.py"
PY = "/Users/ahmedmohammed/Documents/rae/.venv/bin/python3"
proc = subprocess.Popen([PY, SERVER], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1)
failures = []
def send(msg):
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
def recv(expect_id, timeout=30):
import time
start = time.time()
while time.time() - start < timeout:
line = proc.stdout.readline()
if not line:
break
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("id") == expect_id:
return msg
raise TimeoutError(f"no response for id {expect_id}")
def check(name, cond, detail=""):
print(("PASS" if cond else "FAIL"), name, detail)
if not cond:
failures.append(name)
# 1. initialize handshake
send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test"}}})
r = recv(1)
check("initialize", "serverInfo" in r.get("result", {}), r.get("result", {}).get("serverInfo", {}).get("name"))
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
# 2. tools/list
send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
r = recv(2)
tools = [t["name"] for t in r["result"]["tools"]]
check("tools_list", len(tools) == 11, f"{len(tools)} tools")
# 3. live TV calls
send({"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "tv_get_volume", "arguments": {}}})
r = recv(3)
vol_text = r["result"]["content"][0]["text"]
check("tv_get_volume", "Volume:" in vol_text and not r["result"].get("isError", False), vol_text)
send({"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "tv_get_state", "arguments": {}}})
r = recv(4)
check("tv_get_state", not r["result"].get("isError", False), r["result"]["content"][0]["text"][:60])
# wake the screen first (fake_sleep blocks sourcelist)
send({"jsonrpc": "2.0", "id": 41, "method": "tools/call",
"params": {"name": "tv_send_key", "arguments": {"key": "KEY_HOME"}}})
recv(41)
import time as _t; _t.sleep(5)
send({"jsonrpc": "2.0", "id": 5, "method": "tools/call",
"params": {"name": "tv_list_sources", "arguments": {}}})
r = recv(5)
srcs = r["result"]["content"][0]["text"]
check("tv_list_sources", "HDMI1" in srcs, srcs[:60])
proc.stdin.close()
proc.wait(timeout=5)
print("=" * 30)
print("RESULT:", "PASS" if not failures else f"FAIL ({failures})")
sys.exit(0 if not failures else 1)
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Hisense TV MCP server (stdio, JSON-RPC 2.0) for Hermes and any MCP client.
Zero external dependencies. Spawns the proven bridge.py subprocess and
translates MCP tool calls to bridge line-JSON requests.
Client config (Hermes config.yaml / Claude / Cursor):
mcp_servers:
hisense_tv:
command: /Users/ahmedmohammed/Documents/rae/.venv/bin/python3
args: ["/Users/ahmedmohammed/Documents/rae/HisenseBar/mcp_tv_server.py"]
"""
import json
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
BRIDGE = HERE / "bridge.py"
VENV_PY = "/Users/ahmedmohammed/Documents/rae/.venv/bin/python3"
STORE = str(Path.home() / "Library/Application Support/HisenseBar/tokens.json")
HOST = os.environ.get("HISENSE_TV_IP", "192.168.1.146")
MAC = os.environ.get("HISENSE_TV_MAC", "e0:3e:cb:f4:68:be")
PROTOCOL_VERSION = "2024-11-05"
TOOLS = [
{"name": "tv_get_volume", "description": "Get the current TV volume (0-100) and mute state.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}},
{"name": "tv_set_volume", "description": "Set the TV volume (0-100).",
"inputSchema": {"type": "object", "properties": {"volume": {"type": "integer", "minimum": 0, "maximum": 100}},
"required": ["volume"], "additionalProperties": False}},
{"name": "tv_mute_toggle", "description": "Toggle TV mute.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}},
{"name": "tv_send_key", "description": "Send a remote key to the TV (e.g. KEY_HOME, KEY_OK, KEY_UP, KEY_POWER).",
"inputSchema": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"],
"additionalProperties": False}},
{"name": "tv_list_apps", "description": "List installed TV apps (name, appId, url).",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}},
{"name": "tv_launch_app", "description": "Launch an app by its appId+name+url dict (from tv_list_apps) or plain name.",
"inputSchema": {"type": "object", "properties": {
"app_id": {"type": "string"}, "name": {"type": "string"}, "url": {"type": "string"}},
"required": ["name"], "additionalProperties": False}},
{"name": "tv_list_sources", "description": "List TV input sources (TV, HDMI1...).",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}},
{"name": "tv_set_source", "description": "Switch TV input source, e.g. 'HDMI1' (sourceid from tv_list_sources).",
"inputSchema": {"type": "object", "properties": {"source": {"type": "string"}}, "required": ["source"],
"additionalProperties": False}},
{"name": "tv_power_on", "description": "Wake the TV with Wake-on-LAN.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}},
{"name": "tv_power_off", "description": "Power the TV off.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}},
{"name": "tv_get_state", "description": "Get current TV state (on/off, active app/source).",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}},
]
class Bridge:
"""Manages the bridge.py subprocess and id-mapped requests."""
def __init__(self):
self.proc = None
self.lock = threading.Lock()
self.next_id = 1
self.waiting = {} # id -> [event, result]
self.reader = None
self.connected = False
def _spawn(self):
self.connected = False
self.proc = subprocess.Popen(
[VENV_PY, str(BRIDGE), HOST, MAC, STORE],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1,
)
self.reader = threading.Thread(target=self._read_loop, daemon=True)
self.reader.start()
def _read_loop(self):
for line in self.proc.stdout:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
rid = msg.get("id")
if rid in self.waiting:
event, slot = self.waiting.pop(rid)
slot.append(msg)
event.set()
def call(self, cmd: dict, timeout: float = 30.0) -> dict:
with self.lock:
if self.proc is None or self.proc.poll() is not None:
self._spawn()
rid = self.next_id
self.next_id += 1
msg = dict(cmd)
msg["id"] = rid
self.waiting[rid] = [threading.Event(), []]
try:
self.proc.stdin.write(json.dumps(msg) + "\n")
self.proc.stdin.flush()
except (BrokenPipeError, ValueError):
self.proc = None
raise RuntimeError("bridge process died")
event, slot = self.waiting[rid]
if not event.wait(timeout):
self.waiting.pop(rid, None)
raise TimeoutError(f"bridge timeout after {timeout}s for {cmd.get('cmd')}")
return slot[0]
def tv_call(self, cmd: dict, timeout: float = 30.0) -> dict:
"""Ensure the bridge session is connected, then run the command."""
if not self.connected:
r = self.call({"cmd": "connect"}, timeout=45)
self.connected = bool(r.get("ok"))
return self.call(cmd, timeout=timeout)
def close(self):
if self.proc and self.proc.poll() is None:
try:
self.proc.stdin.close()
except Exception:
pass
bridge = Bridge()
def text_result(text: str, is_error: bool = False) -> dict:
return {"content": [{"type": "text", "text": text}], "isError": is_error}
def as_json(obj) -> str:
return json.dumps(obj, ensure_ascii=False, indent=2, default=str)
def tool_call(name: str, args: dict) -> dict:
try:
if name == "tv_get_volume":
r = bridge.tv_call({"cmd": "volume"})
if r.get("volume") is None:
return text_result("TV unreachable or not authenticated (is it on?)", is_error=True)
return text_result(f"Volume: {r['volume']}")
if name == "tv_set_volume":
bridge.tv_call({"cmd": "set_volume", "value": int(args["volume"])})
return text_result(f"Volume set to {args['volume']}")
if name == "tv_mute_toggle":
bridge.tv_call({"cmd": "mute"})
return text_result("Mute toggled")
if name == "tv_send_key":
bridge.tv_call({"cmd": "key", "key": args["key"]})
return text_result(f"Key {args['key']} sent")
if name == "tv_list_apps":
r = bridge.tv_call({"cmd": "apps"}, timeout=45)
apps = [{"name": a.get("name"), "appId": a.get("appId"), "url": a.get("url")}
for a in (r.get("apps") or [])]
if not apps:
return text_result("No apps returned — TV screen likely asleep. "
"Wake it first: tv_send_key KEY_HOME (or tv_power_on), then retry.", is_error=True)
return text_result(as_json(apps))
if name == "tv_launch_app":
app: dict = {"name": args["name"]}
if args.get("app_id"):
app["appId"] = args["app_id"]
if args.get("url"):
app["url"] = args["url"]
bridge.tv_call({"cmd": "launch", "app": app}, timeout=45)
return text_result(f"Launched {args['name']}")
if name == "tv_list_sources":
r = bridge.call({"cmd": "sources"})
return text_result(as_json(r.get("sources") or []))
if name == "tv_set_source":
sid = args["source"]
# rawName lookup for the required sourcename field
r = bridge.tv_call({"cmd": "sources"})
name = next((s.get("sourcename") for s in (r.get("sources") or [])
if s.get("sourceid") == sid), sid)
bridge.tv_call({"cmd": "set_source", "source": sid, "name": name})
return text_result(f"Switched to {sid}")
if name == "tv_power_on":
bridge.tv_call({"cmd": "wake"})
return text_result("Wake-on-LAN packet sent (TV boots in ~5-10s)")
if name == "tv_power_off":
bridge.tv_call({"cmd": "power_off"})
return text_result("TV powered off")
if name == "tv_get_state":
r = bridge.tv_call({"cmd": "state"})
return text_result(as_json(r.get("state") or {}))
return text_result(f"Unknown tool {name}", is_error=True)
except Exception as e:
return text_result(f"{type(e).__name__}: {e}", is_error=True)
def reply(msg_id: int, result: dict):
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": result}) + "\n")
sys.stdout.flush()
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method", "")
msg_id = msg.get("id")
if method == "initialize":
reply(msg_id, {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": {"name": "hisense-tv", "version": "1.0.0"},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
reply(msg_id, {"tools": TOOLS})
elif method == "tools/call":
params = msg.get("params", {})
result = tool_call(params.get("name", ""), params.get("arguments") or {})
reply(msg_id, result)
elif method == "ping":
reply(msg_id, {})
elif msg_id is not None:
reply(msg_id, {"error": {"code": -32601, "message": f"Method not found: {method}"}})
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass
finally:
bridge.close()