From cc2018aae787476a54d08f04a44fad5ab99b9924 Mon Sep 17 00:00:00 2001 From: ahmed Date: Tue, 13 Oct 2026 18:20:11 +0300 Subject: [PATCH] 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 --- .gitea/workflows/build.yml | 30 + .gitignore | 12 + LICENSE | 21 + Package.swift | 13 + README.md | 71 ++ Sources/HisenseBar/BridgeTV.swift | 134 ++++ Sources/HisenseBar/HisenseBarApp.swift | 895 +++++++++++++++++++++++++ bridge.py | 166 +++++ docs/icon.png | Bin 0 -> 45255 bytes make_app.sh | 30 + mcp_test.py | 84 +++ mcp_tv_server.py | 243 +++++++ 12 files changed, 1699 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/HisenseBar/BridgeTV.swift create mode 100644 Sources/HisenseBar/HisenseBarApp.swift create mode 100644 bridge.py create mode 100644 docs/icon.png create mode 100755 make_app.sh create mode 100644 mcp_test.py create mode 100644 mcp_tv_server.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..2236a18 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b06b82d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.build/ +HisenseBar.app/ +*.zip +*.icns +tokens*.json +.DS_Store +.venv/ +# local agent tooling +.agents/ +.claude/ +skills-lock.json +.cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b3f269 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2d7bb50 --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..037ed35 --- /dev/null +++ b/README.md @@ -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). diff --git a/Sources/HisenseBar/BridgeTV.swift b/Sources/HisenseBar/BridgeTV.swift new file mode 100644 index 0000000..5401a7b --- /dev/null +++ b/Sources/HisenseBar/BridgeTV.swift @@ -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] = [:] + 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.. 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) + } + } +} diff --git a/Sources/HisenseBar/HisenseBarApp.swift b/Sources/HisenseBar/HisenseBarApp.swift new file mode 100644 index 0000000..daa6cab --- /dev/null +++ b/Sources/HisenseBar/HisenseBarApp.swift @@ -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? + + 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? + @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() + } +} diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..53aca9b --- /dev/null +++ b/bridge.py @@ -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 +""" +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 diff --git a/docs/icon.png b/docs/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..98856003f3308f860ab1792bc9aa5183dad7bf43 GIT binary patch literal 45255 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 EfAgpPU;qFB literal 0 HcmV?d00001 diff --git a/make_app.sh b/make_app.sh new file mode 100755 index 0000000..74028fd --- /dev/null +++ b/make_app.sh @@ -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' + + + + + CFBundleExecutableHisenseBar + CFBundleIdentifierrae.hisensebar + CFBundleNameHisenseBar + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + CFBundleVersion1 + CFBundleIconFileAppIcon + LSUIElement + NSLocalNetworkUsageDescriptionHisenseBar connects to your Hisense TV on the local network to control it. + + +EOF +echo "Built $APP" diff --git a/mcp_test.py b/mcp_test.py new file mode 100644 index 0000000..38feb24 --- /dev/null +++ b/mcp_test.py @@ -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) diff --git a/mcp_tv_server.py b/mcp_tv_server.py new file mode 100644 index 0000000..a012f21 --- /dev/null +++ b/mcp_tv_server.py @@ -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()