HisenseBar v1.0.0: native menu-bar remote for Hisense VIDAA TVs
- SwiftUI popover: apps grid w/ live search, sources, glass remote, gradient volume slider, animations - Persistent python protocol bridge (vidaa-control, MQTT/TLS :36669) - Wake-on-LAN power-on, one-time PIN pairing with token auto-refresh - MCP server for AI agents (11 tv_* tools) + live test script - Gitea Actions CI workflow
This commit is contained in:
@@ -0,0 +1,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
@@ -0,0 +1,12 @@
|
|||||||
|
.build/
|
||||||
|
HisenseBar.app/
|
||||||
|
*.zip
|
||||||
|
*.icns
|
||||||
|
tokens*.json
|
||||||
|
.DS_Store
|
||||||
|
.venv/
|
||||||
|
# local agent tooling
|
||||||
|
.agents/
|
||||||
|
.claude/
|
||||||
|
skills-lock.json
|
||||||
|
.cache/
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Executable
+30
@@ -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
@@ -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)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user