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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user