#!/usr/bin/env bash # Static device + OBS config snapshot for the OBS Landing overlay. # Output JSON also carries the `rig` field consumed by the Project Loading # overlay's "detected rig :: …" line. # # Modes: # ./telemetry.sh — wrap telemetry.json → telemetry.js # (idempotent; preserves hand edits to JSON) # ./telemetry.sh --collect — re-read hardware + OBS + hostname, # interactively review every field, then wrap # ./telemetry.sh --collect --no-review # — collect, skip prompts (unattended) # # Use --collect after a hardware change, kernel update, or OBS settings change. # For cosmetic tweaks just edit telemetry.json by hand and re-run with no args. set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OBS_DIR="$(cd "$DIR/.." && pwd)" JSON="$OBS_DIR/landing/telemetry.json" JS="$OBS_DIR/landing/telemetry.js" sanitize() { printf '%s' "${1:-}" \ | tr -d '"\\' \ | tr '\n\r\t' ' ' \ | sed 's/ */ /g; s/^ //; s/ $//' } emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; } emit_num() { [[ -z "${1:-}" ]] && printf 'null' || printf '%s' "$1"; } # ── INI reader (section-scoped) ───────────────────── ini_get() { # Usage: ini_get FILE SECTION KEY → prints value (empty if missing) local file="$1" section="$2" key="$3" [[ -f "$file" ]] || return 0 awk -v section="[$section]" -v key="$key" ' $0 == section { in_section = 1; next } /^\[/ { in_section = 0; next } in_section && index($0, key"=") == 1 { sub("^[^=]*=", "") print exit } ' "$file" } # Friendlier names for the encoder ids OBS stores in basic.ini prettify_encoder() { case "${1:-}" in obs_x264|x264) echo "x264";; jim_nvenc|ffmpeg_nvenc|obs_nvenc_h264_tex|obs_nvenc_h264_soft) echo "NVENC H.264";; obs_nvenc_hevc_tex|obs_nvenc_hevc_soft) echo "NVENC HEVC";; obs_nvenc_av1_tex|obs_nvenc_av1_soft) echo "NVENC AV1";; obs_qsv11) echo "QSV";; *) echo "${1:-}";; esac } # Pull a top-level field out of a flat JSON object via python3. json_field() { local file="$1" field="$2" [[ -f "$file" ]] || return 0 python3 - "$file" "$field" <<'PY' 2>/dev/null || true import json, sys try: with open(sys.argv[1]) as f: d = json.load(f) v = d.get(sys.argv[2]) print('' if v is None else v) except Exception: pass PY } # ── OBS config block ──────────────────────────────── collect_obs() { local user_ini="$OBS_DIR/user.ini" [[ -f "$user_ini" ]] || { echo "null"; return; } local profile renderer profile=$(ini_get "$user_ini" "Basic" "Profile") renderer=$(ini_get "$user_ini" "Video" "Renderer") [[ -z "$profile" ]] && profile="Untitled" local profile_dir="$OBS_DIR/basic/profiles/$profile" local ini="$profile_dir/basic.ini" [[ -f "$ini" ]] || { echo "null"; return; } local mode base_w base_h out_w out_h fps mode=$(ini_get "$ini" "Output" "Mode") base_w=$(ini_get "$ini" "Video" "BaseCX") base_h=$(ini_get "$ini" "Video" "BaseCY") out_w=$(ini_get "$ini" "Video" "OutputCX") out_h=$(ini_get "$ini" "Video" "OutputCY") fps=$(ini_get "$ini" "Video" "FPSInt") local color_fmt color_space color_range color_fmt=$(ini_get "$ini" "Video" "ColorFormat") color_space=$(ini_get "$ini" "Video" "ColorSpace") color_range=$(ini_get "$ini" "Video" "ColorRange") local sample_rate ch_setup sample_rate=$(ini_get "$ini" "Audio" "SampleRate") ch_setup=$(ini_get "$ini" "Audio" "ChannelSetup") # Stream encoder + bitrate. Path differs Simple vs Advanced. local enc_raw="" bitrate="" rc="" keyint="" h264_profile="" if [[ "$mode" == "Advanced" ]]; then enc_raw=$(ini_get "$ini" "AdvOut" "Encoder") local sej="$profile_dir/streamEncoder.json" rc=$(json_field "$sej" "rate_control") bitrate=$(json_field "$sej" "bitrate") keyint=$(json_field "$sej" "keyint_sec") h264_profile=$(json_field "$sej" "profile") else enc_raw=$(ini_get "$ini" "SimpleOutput" "StreamEncoder") bitrate=$(ini_get "$ini" "SimpleOutput" "VBitrate") fi local enc_pretty enc_pretty=$(prettify_encoder "$enc_raw") profile=$(sanitize "$profile") renderer=$(sanitize "$renderer") mode=$(sanitize "$mode") color_fmt=$(sanitize "$color_fmt") color_space=$(sanitize "$color_space") color_range=$(sanitize "$color_range") ch_setup=$(sanitize "$ch_setup") enc_pretty=$(sanitize "$enc_pretty") rc=$(sanitize "$rc") h264_profile=$(sanitize "$h264_profile") cat </dev/null 2>&1; then local q q=$(nvidia-smi --query-gpu=name,memory.total \ --format=csv,noheader,nounits 2>/dev/null | head -n1 || true) if [[ -n "$q" ]]; then IFS=',' read -r gpu_name gpu_vram_total_mb <<<"$q" gpu_name=$(sanitize "$gpu_name") gpu_vram_total_mb=$(printf '%s' "$gpu_vram_total_mb" | tr -d ' ') fi fi cpu_model=$(sanitize "$cpu_model") kernel=$(sanitize "$kernel") rig=$(sanitize "$rig") local now_iso; now_iso=$(date -u +%FT%TZ) local obs_block; obs_block=$(collect_obs) local tmp="$JSON.tmp.$$" cat > "$tmp" </dev/null 2>&1; then echo "collect: produced invalid JSON, leaving $tmp for inspection" >&2 exit 1 fi mv -f "$tmp" "$JSON" echo "wrote $JSON" } # Walk every leaf in telemetry.json and prompt for a per-field override. # Preserves JSON types (numbers stay numbers, strings stay strings, the # literal word `null` becomes JSON null). Empty input keeps the current value. # Skips `collectedAt` — it's auto-generated. review() { [[ -f "$JSON" ]] || { echo "review: $JSON not found" >&2; exit 1; } echo echo "── Review telemetry fields ─────────────────────────────" echo " Press Enter to keep the value shown." echo " Type a new value to override (use 'null' to clear)." echo " Ctrl-C to abort without writing." echo local tmp="$JSON.review.$$" if ! python3 - "$JSON" "$tmp" <<'PY' import json, sys src, dst = sys.argv[1], sys.argv[2] with open(src) as f: doc = json.load(f) SKIP = {'collectedAt'} def coerce(raw, original): if raw == '': return original if raw.strip().lower() == 'null': return None if isinstance(original, bool): return raw.strip().lower() in ('1', 'true', 'yes', 'y', 'on') if isinstance(original, int) and not isinstance(original, bool): try: return int(raw) except: return raw if isinstance(original, float): try: return float(raw) except: return raw return raw def walk(node, prefix=''): if isinstance(node, dict): for k in list(node.keys()): path = f'{prefix}.{k}' if prefix else k if k in SKIP: continue v = node[k] if isinstance(v, dict): walk(v, path) else: shown = 'null' if v is None else json.dumps(v, ensure_ascii=False) try: raw = input(f' {path:28s} = {shown:30s} override: ') except EOFError: raw = '' node[k] = coerce(raw, v) walk(doc) with open(dst, 'w') as f: json.dump(doc, f, indent=2, ensure_ascii=False) f.write('\n') PY then echo "review: aborted (or python error) — leaving $JSON unchanged" >&2 rm -f "$tmp" exit 1 fi if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then echo "review: produced invalid JSON, leaving $tmp for inspection" >&2 exit 1 fi mv -f "$tmp" "$JSON" echo echo " ✓ updated $JSON" } # Wraps telemetry.json as `window.__TEL = {...};` so index.html can load it # via a