#!/usr/bin/env bash # FramePort setup from the project page: the same for every Frame and every PC, nothing to copy from the PC. Run in # the Frame's desktop terminal (SteamVR dashboard -> Launch a program -> Desktop, then System -> Konsole): # curl -fsSL https://frameport.app/s | bash # with FramePort open on your PC at Steam Frame -> Connect. Options: --pc (skip the search). # # What it does: # 1. finds FramePort on your network (it announces itself over mDNS while that page is open; else the USB cable's # address and a quick scan of this network) # 2. asks it to set up this Frame: FramePort shows the same 4 digits as this terminal, you click Allow there # 3. runs FramePort's setup script from your PC, the one the typed line `curl -fsS / | bash` runs (it # authorizes the app's key, configures podman, turns on Developer Mode, asks for Lepton) # Nothing changes on the Frame before you allow it on the PC. set -euo pipefail say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } PORTS="8765 8766 8767" USB_PC=10.86.200.234 # the PC's fixed address on the Frame's USB cable network (Developer Mode only) PC="" while [[ $# -gt 0 ]]; do case $1 in --pc) PC=${2:-}; shift 2 ;; --pc=*) PC=${1#--pc=}; shift ;; -h|--help) sed -n '2,13p' "$0" 2>/dev/null || true; exit 0 ;; *) shift ;; esac done for tool in curl python3; do command -v "$tool" >/dev/null || { echo "This needs $tool, which SteamOS normally has. Run the setup command FramePort shows instead."; exit 1; } done # FramePort PCs, one per line: nameaddress:portwords. Stdlib Python: avahi isn't always answering on the # Frame, and its firewall only lets mDNS in on port 5353, so this listens there in the group, like avahi does. find_pcs() { python3 - "$PORTS" "$USB_PC" <<'PY' import json, socket, struct, sys, time, urllib.request from concurrent.futures import ThreadPoolExecutor PORTS = [int(p) for p in sys.argv[1].split()] USB_PC = sys.argv[2] SERVICE = "_frameport-pair._tcp.local" def qname(name): return b"".join(bytes([len(p)]) + p.encode() for p in name.split(".")) + b"\0" def read_name(d, off): labels, jumped, end = [], False, off while True: n = d[off] if n == 0: off += 1 break if n & 0xC0 == 0xC0: ptr = struct.unpack("!H", d[off:off + 2])[0] & 0x3FFF if not jumped: end = off + 2 jumped, off = True, ptr continue labels.append(d[off + 1:off + 1 + n].decode(errors="replace")) off += 1 + n return ".".join(labels), (end if jumped else off) def mdns(seconds=3.0): found, srv, txt, addr = {}, {}, {}, {} try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) if hasattr(socket, "SO_REUSEPORT"): try: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except OSError: pass s.bind(("", 5353)) s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton("224.0.0.251") + socket.inet_aton("0.0.0.0")) query = struct.pack("!6H", 0, 0, 1, 0, 0, 0) + qname(SERVICE) + struct.pack("!HH", 12, 1) s.sendto(query, ("224.0.0.251", 5353)) except OSError: return [] end, again = time.time() + seconds, True while time.time() < end: if again and time.time() > end - seconds / 2: s.sendto(query, ("224.0.0.251", 5353)) again = False s.settimeout(max(0.05, min(0.5, end - time.time()))) try: d, src = s.recvfrom(9000) except socket.timeout: continue try: _, _, qd, an, ns, ar = struct.unpack("!6H", d[:12]) off = 12 for _ in range(qd): _, off = read_name(d, off) off += 4 for _ in range(an + ns + ar): name, off = read_name(d, off) rtype, _, _, rdlen = struct.unpack("!HHIH", d[off:off + 10]) off += 10 if rtype == 12 and name.lower() == SERVICE: found.setdefault(read_name(d, off)[0], src[0]) elif rtype == 33: srv[name] = (read_name(d, off + 6)[0], struct.unpack("!H", d[off + 4:off + 6])[0]) elif rtype == 16: items, p = {}, off while p < off + rdlen: n = d[p] k, _, v = d[p + 1:p + 1 + n].decode(errors="replace").partition("=") items[k] = v p += 1 + n txt[name] = items elif rtype == 1 and rdlen == 4: addr[name] = socket.inet_ntoa(d[off:off + 4]) off += rdlen except (IndexError, struct.error, UnicodeError): continue out = [] for inst, src in found.items(): target, port = srv.get(inst, ("", PORTS[0])) props = txt.get(inst, {}) out.append((props.get("pc") or inst.split(".")[0], f"{addr.get(target) or src}:{port}", props.get("words", ""))) return out def ping(host_port): try: with urllib.request.urlopen(f"http://{host_port}/ping", timeout=1.5) as r: info = json.load(r) return (info.get("pc", host_port), host_port, info.get("words", "")) except Exception: return None def scan(): """This network's /24 (and the USB cable's PC address) for a FramePort setup port.""" hosts = [f"{USB_PC}:{p}" for p in PORTS] try: out = __import__("subprocess").run(["ip", "-4", "-o", "addr"], capture_output=True, text=True).stdout except OSError: out = "" for line in out.splitlines(): parts = line.split() if len(parts) > 3 and parts[1] not in ("lo",) and "/" in parts[3]: mine = parts[3].split("/")[0] base = mine.rsplit(".", 1)[0] hosts += [f"{base}.{i}:{p}" for i in range(1, 255) if f"{base}.{i}" != mine for p in PORTS[:1]] def open_port(hp): h, p = hp.rsplit(":", 1) try: with socket.create_connection((h, int(p)), timeout=0.4): return hp except OSError: return None with ThreadPoolExecutor(64) as ex: live = [hp for hp in ex.map(open_port, hosts) if hp] return [r for r in map(ping, live) if r] pcs = mdns() if not pcs: pcs = scan() seen = set() for name, hp, words in pcs: key = (name, words) if words else hp # one PC on several links (home Wi-Fi and the Frame's hotspot): once if key not in seen: seen.add(key) print(f"{name}\t{hp}\t{words}") PY } say "FramePort setup for $(hostname)" if [[ -n "$PC" ]]; then [[ "$PC" == *:* ]] || PC="$PC:${PORTS%% *}" WORDS="" else echo "Looking for FramePort on your network (in FramePort on your PC: Steam Frame → Start setup)..." mapfile -t pcs < <(find_pcs) if [[ ${#pcs[@]} -eq 0 ]]; then echo echo "FramePort wasn't found. Check that:" echo " - FramePort is open on your PC, at Steam Frame → Start setup" echo " - the Frame and the PC are on the same Wi-Fi (or connected with a USB cable)" echo "Or run the setup command FramePort shows there (Use the setup command)." exit 1 fi pick=0 if [[ ${#pcs[@]} -gt 1 ]]; then echo "More than one FramePort is open on this network:" for i in "${!pcs[@]}"; do IFS=$'\t' read -r name hp words <<<"${pcs[$i]}" echo " $((i + 1))) $name ($hp) $words" done # stdin is this script itself (curl | bash): ask on the terminal; without one, take the first if { exec 3/dev/null; then read -r -u 3 -p "Which one? [1-${#pcs[@]}] " n exec 3<&- [[ "$n" =~ ^[0-9]+$ && $n -ge 1 && $n -le ${#pcs[@]} ]] || { echo "No such number."; exit 1; } pick=$((n - 1)) else echo "No terminal to ask in: using the first. (Choose with --pc .)" fi fi IFS=$'\t' read -r name PC WORDS <<<"${pcs[$pick]}" echo "Found FramePort on $name ($PC)${WORDS:+, PC words: $WORDS}" fi nonce=$(python3 -c 'import secrets; print(secrets.token_hex(12))') digits=$(python3 -c 'import hashlib, sys; print(f"{int(hashlib.sha256(sys.argv[1].encode()).hexdigest()[:8], 16) % 10000:04d}")' "$nonce") tmp=$(mktemp) trap 'rm -f "$tmp"' EXIT status=$(curl -sS -o "$tmp" -w '%{http_code}' -G --data-urlencode "host=$(hostname)" --data-urlencode "nonce=$nonce" \ "http://$PC/hello") || true case $status in 200) reply=$(cat "$tmp") ;; 429) echo "FramePort at $PC already has several setup requests waiting. Allow or deny them there (requests from" echo "setups that ended go away by themselves within a few minutes), then run this again."; exit 1 ;; 000|"") echo "FramePort at $PC didn't answer. Is it still open at Steam Frame → Start setup?"; exit 1 ;; *) echo "FramePort at $PC refused the request (HTTP $status). Run this again, or run the setup command FramePort shows." exit 1 ;; esac id=$(python3 -c 'import json, sys; print(json.loads(sys.argv[1])["id"])' "$reply") WORDS=$(python3 -c 'import json, sys; print(json.loads(sys.argv[1]).get("words", ""))' "$reply") say "FramePort on your PC asks to set up this Frame" printf ' Click Allow there if it shows \033[1;33m%s\033[0m (PC: %s)\n' "$digits" "${WORDS:-?}" code="" for _ in $(seq 12); do # up to about 20 minutes status=$(curl -sS -o "$tmp" -w '%{http_code}' -m 115 "http://$PC/wait?id=$id" || echo 000) case $status in 200) code=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["code"])' "$tmp"); break ;; 202) continue ;; 403) echo "Not allowed on the PC. Nothing was changed on this Frame."; exit 1 ;; *) echo "Lost FramePort at $PC (HTTP $status). Run this again, or run the setup command FramePort shows."; exit 1 ;; esac done [[ -n "$code" ]] || { echo "Not allowed in time. Run this again when you're at your PC."; exit 1; } say "Allowed. Running the setup from $PC" curl -fsS "http://$PC/$code" | bash