Files
curios/tools/harmony-stream.py
T

234 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Measure the companion's HCS1 video stream; no screenshots/files are retained.
Development tool, not the plugin's production player. Requires official HDC,
the signed companion on the phone, and ffmpeg (or ffplay with --view).
"""
import argparse
import json
import os
from pathlib import Path
import re
import secrets
import select
import shutil
import socket
import struct
import subprocess
import threading
import time
BUNDLE = "ink.render.trove.harmonyconnector"
MAX_PACKET = 2 * 1024 * 1024
def read_exact(sock, size, deadline):
result = bytearray()
while len(result) < size:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Video stream timed out")
sock.settimeout(min(remaining, 0.2))
try:
part = sock.recv(size - len(result))
except socket.timeout:
continue
if not part:
raise EOFError("Screen sharing stopped or USB disconnected")
result.extend(part)
return bytes(result)
def read_hello(sock, deadline):
magic, width, height, display_width, display_height, fps = struct.unpack(
">4s5I", read_exact(sock, 24, deadline))
if (magic != b"HCS1" or not 1 <= fps <= 60 or
not all(2 <= d <= 8192 for d in (width, height, display_width, display_height)) or
width % 2 or height % 2 or max(width, height) > 1280):
raise ValueError("Unsupported stream header")
return {"width": width, "height": height, "displayWidth": display_width,
"displayHeight": display_height, "configuredFps": fps}
def read_packet(sock, deadline):
size, flags, pts = struct.unpack(
">IIq", read_exact(sock, 16, deadline))
if not 0 < size <= MAX_PACKET or flags & ~0x7f or pts < 0:
raise ValueError("Invalid video packet")
data = read_exact(sock, size, deadline)
if not data.startswith((b"\0\0\1", b"\0\0\0\1")):
raise ValueError("Encoder did not return Annex B H.264")
return flags, pts, data
class Decoder:
def __init__(self, view):
name = "ffplay" if view else "ffmpeg"
executable = shutil.which(name)
if not executable:
raise RuntimeError(f"Development measurement requires {name} on PATH")
args = [executable, "-hide_banner", "-loglevel", "error",
"-flags", "low_delay", "-probesize", "32", "-analyzeduration", "0",
"-f", "h264", "-i", "pipe:0"]
if view:
args += ["-window_title", "Harmony Connector · H.264 preview", "-autoexit"]
else:
args += ["-progress", "pipe:1", "-stats_period", "0.25", "-f", "null", "-"]
self.frames = 0
self.error = bytearray()
self.process = subprocess.Popen(args, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
os.set_blocking(self.process.stdin.fileno(), False)
self.threads = [threading.Thread(target=self.progress, daemon=True),
threading.Thread(target=self.errors, daemon=True)]
for thread in self.threads:
thread.start()
def progress(self):
for line in self.process.stdout:
if line.startswith(b"frame="):
self.frames = int(line.split(b"=", 1)[1])
def errors(self):
for line in self.process.stderr:
if len(self.error) < 8192:
self.error.extend(line[:8192 - len(self.error)])
def write(self, data):
deadline = time.monotonic() + 1
view = memoryview(data)
while view:
if self.process.poll() is not None:
raise RuntimeError("Video decoder exited: " + self.error.decode(errors="replace"))
if time.monotonic() >= deadline:
raise TimeoutError("Decoder is falling behind; stopped instead of queuing video")
if not select.select([], [self.process.stdin], [], 0.05)[1]:
continue
try:
count = os.write(self.process.stdin.fileno(), view)
view = view[count:]
except BlockingIOError:
continue
def close(self):
self.process.stdin.close()
try:
self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
self.process.terminate()
try:
self.process.wait(timeout=1)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
for thread in self.threads:
thread.join(timeout=1)
self.process.stdout.close()
self.process.stderr.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--hdc", required=True, type=Path)
parser.add_argument("--target")
parser.add_argument("--seconds", type=int, default=20)
parser.add_argument("--view", action="store_true")
args = parser.parse_args()
if not 5 <= args.seconds <= 120 or not args.hdc.is_file():
parser.error("Use an existing HDC executable and 5–120 measurement seconds")
def hdc(*command):
try:
result = subprocess.run([str(args.hdc.resolve()), *command], capture_output=True,
text=True, timeout=10, check=False)
except subprocess.TimeoutExpired:
raise RuntimeError("HDC command timed out") from None
if result.returncode:
raise RuntimeError("HDC command did not complete successfully")
if re.search(r"\b(fail(?:ed)?|error)\b", result.stdout + result.stderr, re.I):
raise RuntimeError("HDC command failed: " + result.stdout + result.stderr)
return result.stdout
targets = [line.split()[0] for line in hdc("list", "targets", "-v").splitlines()
if "Connected" in line.split()]
target = args.target or (targets[0] if len(targets) == 1 else None)
if target not in targets:
raise RuntimeError("Select one connected, authorized phone with --target")
installed = hdc("-t", target, "shell", f"bm dump -n {BUNDLE}")
if BUNDLE not in installed:
raise RuntimeError("Install the signed Harmony Connector companion before measuring")
decoder = Decoder(args.view)
sock = None
forward = False
with socket.socket() as reservation:
reservation.bind(("127.0.0.1", 0))
local_port = reservation.getsockname()[1]
remote_port = 20000 + secrets.randbelow(40000)
local, remote = f"tcp:{local_port}", f"tcp:{remote_port}"
token = secrets.token_hex(32)
frames = total_bytes = 0
started = first_frame = None
try:
hdc("-t", target, "fport", local, remote)
forward = True
hdc("-t", target, "shell", f"aa start -b {BUNDLE} -a EntryAbility -m entry "
f"--ps token {token} --pi port {remote_port}")
print("请在手机点击「共享屏幕」,并同意系统授权。", flush=True)
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
try:
sock = socket.create_connection(("127.0.0.1", local_port), timeout=1)
sock.sendall(token.encode("ascii"))
hello = read_hello(sock, min(deadline, time.monotonic() + 2))
break
except (OSError, EOFError, TimeoutError):
if sock:
sock.close()
sock = None
time.sleep(0.2)
if not sock:
raise TimeoutError("Phone did not start screen sharing")
print(json.dumps({"stage": "connected", **hello}), flush=True)
started = time.monotonic()
last_report = started
while first_frame is None or time.monotonic() - first_frame < args.seconds:
flags, _, data = read_packet(sock, time.monotonic() + (45 if first_frame is None else 3))
decoder.write(data)
total_bytes += len(data)
now = time.monotonic()
if not flags & 8:
frames += 1
if first_frame is None:
first_frame = now
print(json.dumps({"stage": "first-frame", "waitMs": round((now - started) * 1000)}), flush=True)
if first_frame and now - last_report >= 1:
elapsed = now - first_frame
print(json.dumps({"stage": "receiving", "frames": frames,
"receivedFps": round((frames - 1) / elapsed, 1),
"decodedFrames": decoder.frames,
"mbps": round(total_bytes * 8 / elapsed / 1000000, 2)}), flush=True)
last_report = now
elapsed = time.monotonic() - first_frame
finally:
if sock:
try:
sock.sendall(b"\0")
except OSError:
pass
sock.close()
try:
if forward:
hdc("-t", target, "fport", "rm", local, remote)
finally:
decoder.close()
passed = (frames - 1) / elapsed >= 20 and (args.view or decoder.frames >= frames * 0.95)
print(json.dumps({"passed": passed, "frames": frames, "decodedFrames": decoder.frames,
"receivedFps": round((frames - 1) / elapsed, 1), "durationSeconds": round(elapsed, 2),
"endToEndLatencyMeasured": False, "inputSent": False, "screenFilesSaved": False}), flush=True)
if not passed:
raise SystemExit(1)
if __name__ == "__main__":
main()