mirror of
https://github.com/Rulez777/FramePicoTracker.git
synced 2026-10-23 05:01:38 +02:00
288 lines
14 KiB
Python
288 lines
14 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
# Copyright (C) 2026 L8OLOOE and contributors. See CREDITS.md and NOTICE.md.
|
|
"""Read-only Linux diagnostic endpoint for the Frame research workspace."""
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
from pathlib import Path
|
|
import time
|
|
import subprocess
|
|
import threading
|
|
import uuid
|
|
import re
|
|
import tarfile
|
|
import shutil
|
|
from urllib.parse import urlparse
|
|
try:
|
|
from .frame_app_control import AppControl
|
|
from .frame_profile_status import live_status,current_stream
|
|
except ImportError:
|
|
from frame_app_control import AppControl
|
|
from frame_profile_status import live_status,current_stream
|
|
|
|
ROOT = Path('/home/steamos/pico-ram-work')
|
|
START = time.monotonic()
|
|
DATASETS = Path('/home/steamos/.local/share/Steam/config/cv/xrservice/datasets')
|
|
LOCK = threading.Lock()
|
|
RADIO_LOCK = threading.Lock()
|
|
JOBS = {}
|
|
ACTIVE = None
|
|
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
|
|
|
|
|
|
def camera_command(start):
|
|
command = 'start_tracking_recording 1 0 0' if start else 'stop_tracking_recording'
|
|
result = subprocess.run([VRCMD,'--cmd','0',command],capture_output=True,text=True,timeout=5)
|
|
if result.returncode != 0 or 'response: ok' not in result.stdout:
|
|
raise RuntimeError('Camera recording command failed: '+result.stdout+result.stderr)
|
|
return result.stdout.strip()
|
|
PAGE = b'''<!doctype html><meta charset="utf-8"><title>FramePicoTracker</title>
|
|
<body style="font:18px system-ui;background:#101820;color:#eef;padding:30px;max-width:900px">
|
|
<h1>FramePicoTracker</h1><p>Diagnostic capture: cameras, radio packets and Frame poses. Screen and audio recording disabled.</p>
|
|
<button id="capture">Record diagnostics (10 seconds)</button> <button id="refresh">Refresh status</button>
|
|
<p id="result"></p><pre id="status" style="white-space:pre-wrap;font-size:13px"></pre>
|
|
<script>
|
|
async function status(){document.getElementById('status').textContent=JSON.stringify(await(await fetch('/status')).json(),null,2)}
|
|
document.getElementById('refresh').onclick=status;
|
|
document.getElementById('capture').onclick=async()=>{
|
|
const button=document.getElementById('capture');button.disabled=true;
|
|
try{let r=await fetch('/capture',{method:'POST'});let job=await r.json();if(!r.ok)throw Error(job.error);
|
|
document.getElementById('result').textContent='Recording '+job.id;
|
|
while(true){await new Promise(r=>setTimeout(r,1000));let j=await(await fetch('/capture/'+job.id)).json();
|
|
if(j.state==='complete'){let a=document.createElement('a');a.href='/capture/'+job.id+'/bundle';a.textContent='Download diagnostic bundle';document.getElementById('result').replaceChildren(a);break}
|
|
if(j.state==='failed')throw Error(JSON.stringify(j));}await status();
|
|
}catch(e){document.getElementById('result').textContent=e.message}finally{button.disabled=false}}
|
|
status();</script>'''
|
|
|
|
|
|
def dataset_inventory():
|
|
result = []
|
|
for directory in sorted(DATASETS.glob('*.dataset'), key=lambda p:p.stat().st_mtime, reverse=True)[:5]:
|
|
files = [{ 'name':p.name, 'bytes':p.stat().st_size, 'mtime':p.stat().st_mtime}
|
|
for p in directory.iterdir() if p.is_file()]
|
|
result.append({'name':directory.name, 'files':files,
|
|
'scope':'Recorded dataset, not a live camera feed'})
|
|
return result
|
|
|
|
|
|
def capture_worker(identifier):
|
|
"""Record an existing station stream; never open a second SWD owner."""
|
|
global ACTIVE
|
|
prefix=ROOT/('api-'+identifier)
|
|
started=time.time()
|
|
try:
|
|
with LOCK: JOBS[identifier]['state']='running'
|
|
if not radio_status().get('running'):
|
|
raise RuntimeError('Station is not running; diagnostics cannot start radio probes')
|
|
_,log=current_stream(ROOT)
|
|
if not log.exists():
|
|
raise RuntimeError('Station record stream is unavailable')
|
|
received=0
|
|
with log.open('rb') as source, prefix.with_suffix('.records.jsonl').open('wb') as output:
|
|
source.seek(0,2)
|
|
initial=source.tell()
|
|
deadline=time.monotonic()+10
|
|
while time.monotonic()<deadline:
|
|
if not radio_status().get('running'):
|
|
raise RuntimeError('Station stopped or became stale during capture')
|
|
block=source.read(65536)
|
|
if block:
|
|
received+=len(block)
|
|
if received>8*1024*1024:
|
|
raise RuntimeError('Diagnostic byte budget exceeded')
|
|
output.write(block)
|
|
else:
|
|
# Rotation must not replay the saved history as live.
|
|
if log.stat().st_ino!=__import__('os').fstat(source.fileno()).st_ino:
|
|
raise RuntimeError('Station log rotated; repeat capture from current stream')
|
|
time.sleep(.02)
|
|
manifest={'id':identifier,'started_unix':started,'finished_unix':time.time(),
|
|
'radio_bytes':received,'history_bytes_skipped':initial,
|
|
'source':'existing station stream','opens_swd':False,
|
|
'controls_camera_recording':False,'tracker_poses_available':False}
|
|
prefix.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2))
|
|
with tarfile.open(prefix.with_suffix('.tar.gz'),'w:gz') as archive:
|
|
for suffix in ('.records.jsonl','.manifest.json'):
|
|
path=prefix.with_suffix(suffix)
|
|
archive.add(path,arcname=path.name)
|
|
with LOCK: JOBS[identifier].update(state='complete',manifest=manifest)
|
|
except Exception as error:
|
|
with LOCK: JOBS[identifier].update(state='failed',error=str(error))
|
|
finally:
|
|
with LOCK: ACTIVE=None
|
|
|
|
|
|
def start_capture():
|
|
global ACTIVE
|
|
with LOCK:
|
|
if ACTIVE is not None:
|
|
return None
|
|
identifier = uuid.uuid4().hex
|
|
ACTIVE = identifier
|
|
JOBS[identifier] = {'id':identifier,'state':'queued','kind':'existing_station_stream'}
|
|
# Retain a bounded in-memory history; saved artifacts remain on disk.
|
|
for key in list(JOBS)[:-20]:
|
|
if key != identifier: JOBS.pop(key,None)
|
|
threading.Thread(target=capture_worker,args=(identifier,),daemon=True).start()
|
|
return identifier
|
|
|
|
|
|
def radio_status():
|
|
return live_status(ROOT)
|
|
|
|
|
|
def status():
|
|
reports = []
|
|
active_probes = []
|
|
for path in sorted(ROOT.glob('ir-tx*.json'), key=lambda p:p.stat().st_mtime, reverse=True):
|
|
if path.name.endswith('.recovery.json'):
|
|
continue
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
if not data.get('active_ir_tx_probe'):
|
|
continue
|
|
keys = ('tx_diagnostics','slots_granted','session_closed_verified',
|
|
'ram_restored_verified','mpu_restored','flash_written')
|
|
active_probes.append({'source':path.name,
|
|
'age_seconds':time.time()-path.stat().st_mtime,
|
|
'evidence_kind':'Completed TX probe, not proof of command delivery',
|
|
'report':{key:data.get(key) for key in keys}})
|
|
if len(active_probes) == 3:
|
|
break
|
|
except (OSError, ValueError):
|
|
continue
|
|
for path in sorted(ROOT.glob('*.summary.json'), key=lambda p:p.stat().st_mtime, reverse=True)[:5]:
|
|
try:
|
|
reports.append({'name':path.name,'mtime':path.stat().st_mtime,
|
|
'age_seconds':time.time()-path.stat().st_mtime,
|
|
'evidence_kind':'Completed capture; not live device presence',
|
|
'report':json.loads(path.read_text())})
|
|
except (OSError, ValueError):
|
|
reports.append({'name':path.name,'error':'Report incomplete or unreadable'})
|
|
scan = ROOT/'tracker-camera101-scan0.json'
|
|
camera = None
|
|
if scan.exists():
|
|
data = json.loads(scan.read_text())
|
|
counts = [len(r['blobs']) for r in data['frames']]
|
|
camera = {'source':scan.name,'frames':len(counts),'min_blobs':min(counts,default=0),
|
|
'max_blobs':max(counts,default=0),'identity_verified':False}
|
|
return {'utc':datetime.now(timezone.utc).isoformat(),'uptime_seconds':time.monotonic()-START,
|
|
'radio_service':radio_status(),'application':AppControl(ROOT).status(radio_status()),'mode':'local_frame_tracker_api','recent_radio_runs':reports,'recent_active_probes':active_probes,'camera_scan':camera,
|
|
'active_capture':ACTIVE,'datasets':dataset_inventory(),
|
|
'live_camera_available':False,'tracker_poses_available':False}
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def json_response(self, value, code=200):
|
|
body=json.dumps(value).encode()
|
|
self.send_response(code)
|
|
self.send_header('Content-Type','application/json')
|
|
self.send_header('Content-Length',str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_POST(self):
|
|
# Local SSH tunnel only; reject browser-origin requests and non-empty bodies.
|
|
origin = self.headers.get('Origin')
|
|
host = self.headers.get('Host','')
|
|
local_host = re.fullmatch(r'(127\.0\.0\.1|localhost):[0-9]+',host)
|
|
app_route=self.path in ('/profiles/game','/profiles/service') or re.fullmatch(r'/trackers/[0-9a-f]{8}/update',self.path)
|
|
if (self.path not in ('/capture','/radio/start','/radio/stop') and not app_route) or not local_host or (origin and origin != 'http://'+host) or self.headers.get('Transfer-Encoding'):
|
|
self.send_error(403); return
|
|
if self.headers.get('Content-Length','0') != '0':
|
|
self.send_error(400); return
|
|
if app_route:
|
|
if ACTIVE or not RADIO_LOCK.acquire(blocking=False):
|
|
self.json_response({'error':'Radio operation or capture already in progress'},409);return
|
|
try:
|
|
control=AppControl(ROOT)
|
|
value=control.select(self.path.rsplit('/',1)[1]) if self.path.startswith('/profiles/') else control.update(self.path.split('/')[2],radio_status())
|
|
self.json_response(value,202)
|
|
except (ValueError,RuntimeError,OSError,subprocess.TimeoutExpired) as error:
|
|
self.json_response({'error':str(error)},409)
|
|
finally:RADIO_LOCK.release()
|
|
return
|
|
if self.path.startswith('/radio/'):
|
|
if ACTIVE:
|
|
self.json_response({'error':'Wait for capture cleanup'},409);return
|
|
action=self.path.rsplit('/',1)[1]
|
|
if not RADIO_LOCK.acquire(blocking=False):
|
|
self.json_response({'error':'Radio operation already in progress'},409);return
|
|
try:
|
|
control=AppControl(ROOT)
|
|
value=control.select('game') if action=='start' else control.stop()
|
|
self.json_response(value,202)
|
|
except (ValueError,RuntimeError,OSError) as error:
|
|
self.json_response({'error':str(error)},409)
|
|
except subprocess.TimeoutExpired:
|
|
self.json_response({'error':'Service operation timed out; check status before retrying'},504)
|
|
finally: RADIO_LOCK.release()
|
|
return
|
|
if not radio_status().get('running'):
|
|
self.json_response({'error':'Station is not running; diagnostics do not start RF probes'},409);return
|
|
identifier = start_capture()
|
|
self.json_response({'id':identifier} if identifier else {'error':'Capture already running'},
|
|
202 if identifier else 409)
|
|
|
|
def do_GET(self):
|
|
path = urlparse(self.path).path
|
|
if path == '/':
|
|
self.send_response(200)
|
|
self.send_header('Content-Type','text/html; charset=utf-8')
|
|
self.send_header('Content-Length',str(len(PAGE)))
|
|
self.end_headers(); self.wfile.write(PAGE); return
|
|
dataset_match = re.fullmatch(r'/dataset/([A-Za-z0-9_.-]+\.dataset)/([A-Za-z0-9_.-]+)',path)
|
|
if dataset_match:
|
|
directory,filename = dataset_match.groups()
|
|
root = DATASETS.resolve()
|
|
file = (DATASETS/directory/filename).resolve()
|
|
if root not in file.parents or not file.is_file(): self.send_error(404); return
|
|
# Serve only dataset files, never arbitrary headset paths.
|
|
size = file.stat().st_size
|
|
self.send_response(200)
|
|
self.send_header('Content-Type','application/octet-stream')
|
|
self.send_header('Content-Length',str(size))
|
|
self.end_headers()
|
|
with file.open('rb') as source:
|
|
remaining = size
|
|
while remaining:
|
|
block = source.read(min(65536,remaining))
|
|
if not block: break
|
|
self.wfile.write(block); remaining -= len(block)
|
|
return
|
|
match = re.fullmatch(r'/capture/([0-9a-f]{32})(/bundle)?',path)
|
|
if match:
|
|
identifier,bundle = match.groups()
|
|
with LOCK: job = dict(JOBS.get(identifier,{}))
|
|
if not job: self.send_error(404); return
|
|
if not bundle: self.json_response(job); return
|
|
archive = ROOT/('api-'+identifier+'.tar.gz')
|
|
if job['state'] not in ('complete','failed') or not archive.is_file():
|
|
self.send_error(409); return
|
|
self.send_response(200)
|
|
self.send_header('Content-Type','application/gzip')
|
|
self.send_header('Content-Length',str(archive.stat().st_size))
|
|
self.end_headers()
|
|
with archive.open('rb') as source:
|
|
while block := source.read(65536): self.wfile.write(block)
|
|
return
|
|
if path != '/status': self.send_error(404); return
|
|
try:
|
|
body=json.dumps(status()).encode()
|
|
except (OSError,ValueError) as error:
|
|
self.send_error(503,str(error))
|
|
return
|
|
self.send_response(200)
|
|
self.send_header('Content-Type','application/json')
|
|
self.send_header('Content-Length',str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
|
|
if __name__=='__main__':
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--port',type=int,default=18741)
|
|
args=parser.parse_args()
|
|
ThreadingHTTPServer(('127.0.0.1',args.port),Handler).serve_forever()
|