mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
Payloads: HearBridge status and controls
HearBridge (Bluetooth headset audio) gets an integration card on On your
PS5. The engine talks to it for the app (its page sends no CORS
headers): new /api/ps5/hearbridge/{status,saved,devices,action} routes
find it on 8090..8095, read its status with the token stripped, and
pass only volume, mute, night mode, latency, reconnect, scan, select
and stop, each checked against the ranges HearBridge takes. The token
is read fresh per change and never leaves the engine.
The card shows the stream state, headset, battery, link quality,
delay, codec, volume and game profile; Manage has the volume and
latency sliders, mute, night mode, reconnect, scan and connect, saved
headsets, recent events and a link to HearBridge's own page for
pairing, EQ and backups. Stop uses HearBridge's clean stop. An absent
or older HearBridge shows "HearBridge didn't answer" with the page
link. A card opened by a link now scrolls into view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
1 parent
d6c47afd9e
commit
c5a239238c
31 files changed
+2271
-8
No files matched your search
@@ -0,0 +1,570 @@
|
||||
//! HearBridge (X-F1REBALL-X/HearBridge-PS5): Bluetooth headset audio for the PS5, with its
|
||||
//! own small HTTP API. The app reads and drives it through the engine because the page
|
||||
//! sends no CORS headers, so a browser page on another origin can't call it directly.
|
||||
//!
|
||||
//! Its API, read from its source (src/http.c):
|
||||
//! HTTP/1.1, `Connection: close`, on TCP 8090; when that port is taken it tries the next
|
||||
//! five (8091..8095).
|
||||
//! GET /api/status one JSON object: version, token, state, device, gain_pct, muted,
|
||||
//! codec, latency{target_ms,estimate_ms}, battery{pct}, link{score,
|
||||
//! rssi}, night{on}, lat_extra{auto}, game{id,name,profile}, events[]…
|
||||
//! GET /api/saved {"devices":[{addr,name,kind,current}]} paired headsets
|
||||
//! GET /api/devices {"version":1,"devices":[{index,addr,name,kind,rssi}]} last scan
|
||||
//! POST /api/volume?pct=0..500 /api/mute?on=0|1 /api/night?on=0|1
|
||||
//! /api/latency?auto=0|1 or ?ms=40..400 /api/reconnect /api/stop
|
||||
//! /api/select?index=N or ?addr=XX:XX:.. /api/scan?user=1 (without user=1 a
|
||||
//! scan is ignored: an inquiry holds the radio the DualSense shares)
|
||||
//! Every POST needs header `X-HB-Token` with the token from /api/status (403 otherwise).
|
||||
//!
|
||||
//! Nothing is cached on disk. The port a console's HearBridge answered on is remembered in
|
||||
//! memory so the next call tries it first; the token is read fresh for every change.
|
||||
//!
|
||||
//! Plain HTTP over a TcpStream, like `payload_manager`: the Android engine has no HTTP
|
||||
//! client crate.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// HearBridge's port, then the five it falls back to.
|
||||
pub const HEARBRIDGE_PORTS: [u16; 6] = [8090, 8091, 8092, 8093, 8094, 8095];
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// /api/status is at most 32 KiB (its own buffer); saved/devices at most 8 KiB.
|
||||
const MAX_RESPONSE: usize = 256 * 1024;
|
||||
|
||||
fn port_cache() -> &'static Mutex<HashMap<String, u16>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, u16>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A HearBridge that answered: its port, its status (without the token) and the token.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Found {
|
||||
pub port: u16,
|
||||
pub status: Value,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Find HearBridge on `host` and read its status.
|
||||
pub fn status(host: &str) -> Result<Found, String> {
|
||||
find_on(host, &HEARBRIDGE_PORTS)
|
||||
}
|
||||
|
||||
fn find_on(host: &str, ports: &[u16]) -> Result<Found, String> {
|
||||
let cached = port_cache().lock().ok().and_then(|c| c.get(host).copied());
|
||||
let mut order: Vec<u16> = Vec::with_capacity(ports.len());
|
||||
if let Some(p) = cached.filter(|p| ports.contains(p)) {
|
||||
order.push(p);
|
||||
}
|
||||
order.extend(ports.iter().copied().filter(|p| Some(*p) != cached));
|
||||
let mut last = String::new();
|
||||
for port in order {
|
||||
match status_at(host, port) {
|
||||
Ok(found) => {
|
||||
if let Ok(mut c) = port_cache().lock() {
|
||||
c.insert(host.to_string(), port);
|
||||
}
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
if let Ok(mut c) = port_cache().lock() {
|
||||
c.remove(host);
|
||||
}
|
||||
let range = match (ports.first(), ports.last()) {
|
||||
(Some(a), Some(b)) if a != b => format!("{a}-{b}"),
|
||||
(Some(a), _) => a.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
Err(format!(
|
||||
"HearBridge didn't answer on {host} port {range} ({last})"
|
||||
))
|
||||
}
|
||||
|
||||
fn status_at(host: &str, port: u16) -> Result<Found, String> {
|
||||
let (code, body) = request(host, port, "GET", "/api/status", None)?;
|
||||
if code != 200 {
|
||||
return Err(format!(":{port} answered HTTP {code}"));
|
||||
}
|
||||
parse_status(&body).map(|(status, token)| Found {
|
||||
port,
|
||||
status,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
/// The status object without its token, and the token. Anything that isn't a JSON object
|
||||
/// with a `version` string is not HearBridge (another service on that port).
|
||||
fn parse_status(body: &[u8]) -> Result<(Value, String), String> {
|
||||
let mut v: Value =
|
||||
serde_json::from_slice(body).map_err(|_| "not HearBridge (no JSON status)".to_string())?;
|
||||
let obj = v
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "not HearBridge (status is not an object)".to_string())?;
|
||||
if !obj.get("version").is_some_and(Value::is_string) {
|
||||
return Err("not HearBridge (no version in its status)".into());
|
||||
}
|
||||
let token = match obj.remove("token") {
|
||||
Some(Value::String(t)) => t,
|
||||
_ => String::new(),
|
||||
};
|
||||
Ok((v, token))
|
||||
}
|
||||
|
||||
/// Which list to read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceList {
|
||||
/// Paired headsets (`/api/saved`).
|
||||
Saved,
|
||||
/// The last scan's results (`/api/devices`).
|
||||
Scanned,
|
||||
}
|
||||
|
||||
/// `{"devices":[…]}` from HearBridge; an empty list when it has none or sent something else.
|
||||
pub fn devices(host: &str, which: DeviceList) -> Result<Value, String> {
|
||||
devices_on(host, which, &HEARBRIDGE_PORTS)
|
||||
}
|
||||
|
||||
fn devices_on(host: &str, which: DeviceList, ports: &[u16]) -> Result<Value, String> {
|
||||
let found = find_on(host, ports)?;
|
||||
let path = match which {
|
||||
DeviceList::Saved => "/api/saved",
|
||||
DeviceList::Scanned => "/api/devices",
|
||||
};
|
||||
let (code, body) = request(host, found.port, "GET", path, None)?;
|
||||
if code != 200 {
|
||||
return Err(format!("HearBridge could not list devices (HTTP {code})"));
|
||||
}
|
||||
Ok(parse_devices(&body))
|
||||
}
|
||||
|
||||
fn parse_devices(body: &[u8]) -> Value {
|
||||
let list = serde_json::from_slice::<Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("devices").filter(|d| d.is_array()).cloned())
|
||||
.unwrap_or_else(|| Value::Array(Vec::new()));
|
||||
serde_json::json!({ "devices": list })
|
||||
}
|
||||
|
||||
/// An action's parameters, as the app sends them.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ActionParams {
|
||||
pub pct: Option<i64>,
|
||||
pub on: Option<bool>,
|
||||
pub index: Option<i64>,
|
||||
pub addr: Option<String>,
|
||||
pub auto: Option<bool>,
|
||||
pub ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// The changes the app makes. Pairing, EQ, backups and the rest stay on HearBridge's page.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Volume(u16),
|
||||
Mute(bool),
|
||||
Reconnect,
|
||||
SelectIndex(u8),
|
||||
SelectAddr(String),
|
||||
Scan,
|
||||
Night(bool),
|
||||
LatencyAuto(bool),
|
||||
LatencyMs(u16),
|
||||
Stop,
|
||||
}
|
||||
|
||||
fn is_mac(s: &str) -> bool {
|
||||
let b = s.as_bytes();
|
||||
b.len() == 17
|
||||
&& b.iter().enumerate().all(|(i, c)| {
|
||||
if i % 3 == 2 {
|
||||
*c == b':'
|
||||
} else {
|
||||
c.is_ascii_hexdigit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// The action `name` with its parameters checked against the ranges HearBridge takes.
|
||||
/// Anything else is refused here, so only these requests ever reach the console.
|
||||
pub fn parse(name: &str, p: &ActionParams) -> Result<Action, String> {
|
||||
let need_on = || p.on.ok_or_else(|| format!("{name} needs on"));
|
||||
Ok(match name {
|
||||
"volume" => match p.pct {
|
||||
Some(v) if (0..=500).contains(&v) => Action::Volume(v as u16),
|
||||
_ => return Err("volume needs pct 0-500".into()),
|
||||
},
|
||||
"mute" => Action::Mute(need_on()?),
|
||||
"night" => Action::Night(need_on()?),
|
||||
"reconnect" => Action::Reconnect,
|
||||
"scan" => Action::Scan,
|
||||
"stop" => Action::Stop,
|
||||
"select" => match (&p.addr, p.index) {
|
||||
(Some(a), _) if is_mac(a) => Action::SelectAddr(a.to_ascii_uppercase()),
|
||||
(Some(_), _) => return Err("select needs a Bluetooth address".into()),
|
||||
(None, Some(i)) if (0..64).contains(&i) => Action::SelectIndex(i as u8),
|
||||
_ => return Err("select needs index 0-63 or addr".into()),
|
||||
},
|
||||
"latency" => match (p.auto, p.ms) {
|
||||
(Some(a), _) => Action::LatencyAuto(a),
|
||||
(None, Some(ms)) if (40..=400).contains(&ms) => Action::LatencyMs(ms as u16),
|
||||
_ => return Err("latency needs auto or ms 40-400".into()),
|
||||
},
|
||||
other => return Err(format!("unknown HearBridge action {other:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn path(&self) -> String {
|
||||
let b = |on: bool| if on { 1 } else { 0 };
|
||||
match self {
|
||||
Action::Volume(v) => format!("/api/volume?pct={v}"),
|
||||
Action::Mute(on) => format!("/api/mute?on={}", b(*on)),
|
||||
Action::Night(on) => format!("/api/night?on={}", b(*on)),
|
||||
Action::Reconnect => "/api/reconnect".into(),
|
||||
Action::Scan => "/api/scan?user=1".into(),
|
||||
Action::Stop => "/api/stop".into(),
|
||||
Action::SelectIndex(i) => format!("/api/select?index={i}"),
|
||||
Action::SelectAddr(a) => format!("/api/select?addr={a}"),
|
||||
Action::LatencyAuto(a) => format!("/api/latency?auto={}", b(*a)),
|
||||
Action::LatencyMs(ms) => format!("/api/latency?ms={ms}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `action` to HearBridge on `host`. Returns its answer: the new status (token removed)
|
||||
/// for most actions, `{"ok":1}` for select/scan/reconnect.
|
||||
pub fn act(host: &str, action: &Action) -> Result<Value, String> {
|
||||
act_on(host, action, &HEARBRIDGE_PORTS)
|
||||
}
|
||||
|
||||
fn act_on(host: &str, action: &Action, ports: &[u16]) -> Result<Value, String> {
|
||||
let path = action.path();
|
||||
let mut found = find_on(host, ports)?;
|
||||
for attempt in 0..2 {
|
||||
if found.token.is_empty() {
|
||||
return Err("HearBridge gave no token; it may be too old for changes from here".into());
|
||||
}
|
||||
let (code, body) = request(host, found.port, "POST", &path, Some(&found.token))?;
|
||||
// 403: the token changed (HearBridge restarted between the read and the change).
|
||||
if code == 403 && attempt == 0 {
|
||||
found = status_at(host, found.port).or_else(|_| find_on(host, ports))?;
|
||||
continue;
|
||||
}
|
||||
if code != 200 {
|
||||
let msg = String::from_utf8_lossy(&body);
|
||||
return Err(format!(
|
||||
"HearBridge refused {path} (HTTP {code}: {})",
|
||||
msg.trim()
|
||||
));
|
||||
}
|
||||
let mut v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("token");
|
||||
}
|
||||
return Ok(v);
|
||||
}
|
||||
Err("HearBridge refused the token".into())
|
||||
}
|
||||
|
||||
fn request(
|
||||
host: &str,
|
||||
port: u16,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<(u16, Vec<u8>), String> {
|
||||
let addr = crate::payload_lifecycle::join_host_port(host, port);
|
||||
let sock = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| format!("resolve {addr}: {e}"))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("resolve {addr}: no address"))?;
|
||||
let mut stream = TcpStream::connect_timeout(&sock, CONNECT_TIMEOUT)
|
||||
.map_err(|e| format!("connect {addr}: {e}"))?;
|
||||
stream.set_read_timeout(Some(IO_TIMEOUT)).ok();
|
||||
stream.set_write_timeout(Some(IO_TIMEOUT)).ok();
|
||||
stream
|
||||
.write_all(request_head(method, path, &addr, token).as_bytes())
|
||||
.map_err(|e| format!("send to {addr}: {e}"))?;
|
||||
let mut resp = Vec::new();
|
||||
stream
|
||||
.take(MAX_RESPONSE as u64 + 1)
|
||||
.read_to_end(&mut resp)
|
||||
.map_err(|e| format!("read from {addr}: {e}"))?;
|
||||
if resp.len() > MAX_RESPONSE {
|
||||
return Err(format!("{addr} sent more than {MAX_RESPONSE} bytes"));
|
||||
}
|
||||
parse_response(&resp).ok_or_else(|| format!("{addr} sent a response that is not HTTP"))
|
||||
}
|
||||
|
||||
/// The request line and headers. The User-Agent starts with "curl" on purpose: HearBridge
|
||||
/// treats a status read from anything else as its page being open in a browser, which
|
||||
/// starts extra Bluetooth traffic (a volume re-query and a link wake) meant for someone
|
||||
/// looking at that page (http.c `/api/status`, main.c `lw_tick`).
|
||||
fn request_head(method: &str, path: &str, addr: &str, token: Option<&str>) -> String {
|
||||
let mut head = format!(
|
||||
"{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nUser-Agent: curl-compatible ps5upload\r\n"
|
||||
);
|
||||
if let Some(t) = token {
|
||||
head.push_str(&format!("X-HB-Token: {t}\r\n"));
|
||||
}
|
||||
if method == "POST" {
|
||||
head.push_str("Content-Length: 0\r\n");
|
||||
}
|
||||
head.push_str("\r\n");
|
||||
head
|
||||
}
|
||||
|
||||
fn parse_response(resp: &[u8]) -> Option<(u16, Vec<u8>)> {
|
||||
let split = resp.windows(4).position(|w| w == b"\r\n\r\n")?;
|
||||
let head = std::str::from_utf8(&resp[..split]).ok()?;
|
||||
let status: u16 = head
|
||||
.lines()
|
||||
.next()?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse()
|
||||
.ok()?;
|
||||
Some((status, resp[split + 4..].to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
/// One request as the fake server saw it.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Seen {
|
||||
line: String,
|
||||
token: Option<String>,
|
||||
ua: Option<String>,
|
||||
}
|
||||
|
||||
/// A fake HearBridge on an ephemeral port: answers each request with `reply(line)`.
|
||||
fn fake(
|
||||
reply: impl Fn(&str, Option<&str>) -> (u16, String) + Send + Sync + 'static,
|
||||
) -> (u16, Arc<Mutex<Vec<Seen>>>) {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen2 = seen.clone();
|
||||
let reply = Arc::new(reply);
|
||||
thread::spawn(move || {
|
||||
for s in l.incoming() {
|
||||
let Ok(mut s) = s else { continue };
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut got = Vec::new();
|
||||
while !got.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => got.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&got).to_string();
|
||||
let line = text.lines().next().unwrap_or("").to_string();
|
||||
let header = |name: &str| {
|
||||
text.lines().find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.eq_ignore_ascii_case(name).then(|| v.trim().to_string())
|
||||
})
|
||||
};
|
||||
let token = header("X-HB-Token");
|
||||
seen2.lock().unwrap().push(Seen {
|
||||
line: line.clone(),
|
||||
token: token.clone(),
|
||||
ua: header("User-Agent"),
|
||||
});
|
||||
let (code, body) = reply(&line, token.as_deref());
|
||||
let _ = write!(
|
||||
s,
|
||||
"HTTP/1.1 {code} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
});
|
||||
(port, seen)
|
||||
}
|
||||
|
||||
fn closed_port() -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
const STATUS: &str = r#"{"version":"1.9.0","token":"abc123","connected":1,"state":"streaming","device":"WH-1000XM4","gain_pct":120,"muted":0,"codec":"SBC","latency":{"target_ms":120,"estimate_ms":180},"battery":{"status":"ok","level":3,"pct":80},"link":{"score":92,"rssi":-50},"events":["Connected"]}"#;
|
||||
|
||||
#[test]
|
||||
fn reads_the_status_without_handing_out_the_token() {
|
||||
let (port, seen) = fake(|_, _| (200, STATUS.into()));
|
||||
let f = find_on("127.0.0.1", &[port]).unwrap();
|
||||
assert_eq!(f.port, port);
|
||||
assert_eq!(f.status["device"], "WH-1000XM4");
|
||||
assert!(f.status.get("token").is_none());
|
||||
assert_eq!(f.token, "abc123");
|
||||
let s = seen.lock().unwrap();
|
||||
assert_eq!(s[0].line, "GET /api/status HTTP/1.1");
|
||||
// Not counted as HearBridge's page being open (see request_head).
|
||||
assert!(s[0].ua.as_deref().unwrap().starts_with("curl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_an_older_status_with_few_fields() {
|
||||
let (status, token) = parse_status(br#"{"version":"0.3"}"#).unwrap();
|
||||
assert_eq!(status["version"], "0.3");
|
||||
assert_eq!(token, "");
|
||||
assert!(parse_status(b"<html>").is_err());
|
||||
assert!(parse_status(br#"{"ok":1}"#).is_err());
|
||||
assert!(parse_status(br#"[1]"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_next_port_like_hearbridge_does() {
|
||||
let dead = closed_port();
|
||||
// Something else on the second port: answers, but isn't HearBridge.
|
||||
let (other, _) = fake(|_, _| (200, "<html>hi</html>".into()));
|
||||
let (hb, _) = fake(|_, _| (200, STATUS.into()));
|
||||
let host = "127.0.0.1";
|
||||
let f = find_on(host, &[dead, other, hb]).unwrap();
|
||||
assert_eq!(f.port, hb);
|
||||
let err = find_on(host, &[dead]).unwrap_err();
|
||||
assert!(err.contains("HearBridge didn't answer"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_the_token_with_every_change() {
|
||||
let (port, seen) = fake(|line, token| {
|
||||
if line.starts_with("GET /api/status") {
|
||||
(200, STATUS.into())
|
||||
} else if token == Some("abc123") {
|
||||
(
|
||||
200,
|
||||
r#"{"version":"1.9.0","token":"abc123","gain_pct":200}"#.into(),
|
||||
)
|
||||
} else {
|
||||
(403, r#"{"error":"token"}"#.into())
|
||||
}
|
||||
});
|
||||
let v = act_on("127.0.0.1", &Action::Volume(200), &[port]).unwrap();
|
||||
assert_eq!(v["gain_pct"], 200);
|
||||
assert!(v.get("token").is_none());
|
||||
let s = seen.lock().unwrap();
|
||||
let post = s.iter().find(|r| r.line.starts_with("POST")).unwrap();
|
||||
assert_eq!(post.line, "POST /api/volume?pct=200 HTTP/1.1");
|
||||
assert_eq!(post.token.as_deref(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rereads_the_token_once_when_hearbridge_restarted() {
|
||||
let calls = Arc::new(Mutex::new(0));
|
||||
let c2 = calls.clone();
|
||||
let (port, _) = fake(move |line, token| {
|
||||
if line.starts_with("GET /api/status") {
|
||||
let mut n = c2.lock().unwrap();
|
||||
*n += 1;
|
||||
// First read hands out a token that is stale by the time it is used.
|
||||
let t = if *n == 1 { "old" } else { "new" };
|
||||
(200, format!(r#"{{"version":"1.9.0","token":"{t}"}}"#))
|
||||
} else if token == Some("new") {
|
||||
(200, r#"{"ok":1}"#.into())
|
||||
} else {
|
||||
(403, r#"{"error":"token"}"#.into())
|
||||
}
|
||||
});
|
||||
let v = act_on("127.0.0.1", &Action::Reconnect, &[port]).unwrap();
|
||||
assert_eq!(v["ok"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_token_is_a_clear_error() {
|
||||
let (port, _) = fake(|_, _| (200, r#"{"version":"0.3"}"#.into()));
|
||||
let e = act_on("127.0.0.1", &Action::Stop, &[port]).unwrap_err();
|
||||
assert!(e.contains("no token"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_devices_and_survives_garbage() {
|
||||
let (port, _) = fake(|line, _| {
|
||||
if line.starts_with("GET /api/status") {
|
||||
(200, STATUS.into())
|
||||
} else if line.starts_with("GET /api/saved") {
|
||||
(
|
||||
200,
|
||||
r#"{"devices":[{"addr":"AA:BB:CC:DD:EE:FF","name":"Buds","kind":"audio","current":1}]}"#
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
(200, "not json".into())
|
||||
}
|
||||
});
|
||||
let saved = devices_on("127.0.0.1", DeviceList::Saved, &[port]).unwrap();
|
||||
assert_eq!(saved["devices"][0]["name"], "Buds");
|
||||
let scanned = devices_on("127.0.0.1", DeviceList::Scanned, &[port]).unwrap();
|
||||
assert_eq!(scanned, serde_json::json!({ "devices": [] }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_allowlisted_actions_with_sane_values_pass() {
|
||||
let p = |f: fn(&mut ActionParams)| {
|
||||
let mut a = ActionParams::default();
|
||||
f(&mut a);
|
||||
a
|
||||
};
|
||||
assert_eq!(
|
||||
Action::parse("volume", &p(|a| a.pct = Some(500)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/volume?pct=500"
|
||||
);
|
||||
assert!(Action::parse("volume", &p(|a| a.pct = Some(501))).is_err());
|
||||
assert!(Action::parse("volume", &p(|_| {})).is_err());
|
||||
assert_eq!(
|
||||
Action::parse("mute", &p(|a| a.on = Some(true)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/mute?on=1"
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("scan", &p(|_| {})).unwrap().path(),
|
||||
"/api/scan?user=1"
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("select", &p(|a| a.index = Some(3)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/select?index=3"
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("select", &p(|a| a.addr = Some("aa:bb:cc:dd:ee:ff".into())))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/select?addr=AA:BB:CC:DD:EE:FF"
|
||||
);
|
||||
assert!(Action::parse("select", &p(|a| a.addr = Some("x&y=1".into()))).is_err());
|
||||
assert!(Action::parse("select", &p(|a| a.index = Some(64))).is_err());
|
||||
assert_eq!(
|
||||
Action::parse("latency", &p(|a| a.auto = Some(false)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/latency?auto=0"
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("latency", &p(|a| a.ms = Some(40)))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/latency?ms=40"
|
||||
);
|
||||
assert!(Action::parse("latency", &p(|a| a.ms = Some(39))).is_err());
|
||||
for refused in ["forget", "eq", "restore", "backup", "hci", "../stop", ""] {
|
||||
assert!(Action::parse(refused, &p(|_| {})).is_err(), "{refused}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub mod fs_ops;
|
||||
pub mod fw_spoof;
|
||||
pub mod game_meta;
|
||||
pub mod health;
|
||||
pub mod hearbridge;
|
||||
pub mod hw;
|
||||
pub mod installer_client;
|
||||
pub mod local_image;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! `/api/ps5/hearbridge/*` — the console's HearBridge (Bluetooth headset audio, HTTP on
|
||||
//! :8090..8095) as the app sees it: its status, its saved and scanned headsets, and the
|
||||
//! few changes the app makes. Every call is a plain HTTP request to the console
|
||||
//! (`ps5upload_core::hearbridge`); its token never leaves the engine.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use ps5upload_core::hearbridge as hb;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct HostParams {
|
||||
#[serde(alias = "addr")]
|
||||
host: String,
|
||||
}
|
||||
|
||||
fn bad(e: String) -> Response {
|
||||
crate::json_err(axum::http::StatusCode::BAD_GATEWAY, e).into_response()
|
||||
}
|
||||
|
||||
fn host(h: &str) -> String {
|
||||
ps5upload_core::events::console_host(h.trim())
|
||||
}
|
||||
|
||||
/// GET /api/ps5/hearbridge/status?host= — `{ok, present:true, port, status}`, or
|
||||
/// `{ok, present:false, error}` when nothing answers on 8090..8095.
|
||||
pub(crate) async fn status(
|
||||
State(_s): State<crate::AppState>,
|
||||
Query(q): Query<HostParams>,
|
||||
) -> Response {
|
||||
let h = host(&q.host);
|
||||
match tokio::task::spawn_blocking(move || hb::status(&h)).await {
|
||||
Ok(Ok(f)) => Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"present": true,
|
||||
"port": f.port,
|
||||
"status": f.status,
|
||||
}))
|
||||
.into_response(),
|
||||
Ok(Err(e)) => {
|
||||
Json(serde_json::json!({ "ok": true, "present": false, "error": e })).into_response()
|
||||
}
|
||||
Err(e) => bad(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(q: HostParams, which: hb::DeviceList) -> Response {
|
||||
let h = host(&q.host);
|
||||
match tokio::task::spawn_blocking(move || hb::devices(&h, which)).await {
|
||||
Ok(Ok(v)) => {
|
||||
Json(serde_json::json!({ "ok": true, "devices": v["devices"] })).into_response()
|
||||
}
|
||||
Ok(Err(e)) => bad(e),
|
||||
Err(e) => bad(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/ps5/hearbridge/saved?host= — the paired headsets `{ok, devices}`.
|
||||
pub(crate) async fn saved(
|
||||
State(_s): State<crate::AppState>,
|
||||
Query(q): Query<HostParams>,
|
||||
) -> Response {
|
||||
list(q, hb::DeviceList::Saved).await
|
||||
}
|
||||
|
||||
/// GET /api/ps5/hearbridge/devices?host= — the last scan's results `{ok, devices}`.
|
||||
pub(crate) async fn devices(
|
||||
State(_s): State<crate::AppState>,
|
||||
Query(q): Query<HostParams>,
|
||||
) -> Response {
|
||||
list(q, hb::DeviceList::Scanned).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ActionReq {
|
||||
/// The console (`addr` here is the headset's Bluetooth address, see `ActionParams`).
|
||||
host: String,
|
||||
action: String,
|
||||
#[serde(flatten)]
|
||||
params: hb::ActionParams,
|
||||
}
|
||||
|
||||
/// POST /api/ps5/hearbridge/action `{host, action, pct?|on?|index?|addr?|auto?|ms?}` —
|
||||
/// one of volume, mute, reconnect, select, scan, night, latency, stop. Answers
|
||||
/// `{ok, result}` with HearBridge's reply (its new status, token removed).
|
||||
pub(crate) async fn action(
|
||||
State(_s): State<crate::AppState>,
|
||||
Json(req): Json<ActionReq>,
|
||||
) -> Response {
|
||||
let a = match hb::Action::parse(&req.action, &req.params) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return crate::json_err(axum::http::StatusCode::BAD_REQUEST, e).into_response(),
|
||||
};
|
||||
let h = host(&req.host);
|
||||
crate::log_info!("hearbridge {}: host={h}", req.action);
|
||||
match tokio::task::spawn_blocking(move || hb::act(&h, &a)).await {
|
||||
Ok(Ok(v)) => Json(serde_json::json!({ "ok": true, "result": v })).into_response(),
|
||||
Ok(Err(e)) => bad(e),
|
||||
Err(e) => bad(format!("{e}")),
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ mod folder_build;
|
||||
mod fpkg_api;
|
||||
mod fpkg_firmware;
|
||||
mod games_api;
|
||||
mod hearbridge_api;
|
||||
mod helper_mirror;
|
||||
mod helper_stop;
|
||||
mod icon_cache;
|
||||
@@ -10701,6 +10702,10 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
|
||||
.route("/api/ps5/pldmgr/delete", post(pldmgr_api::delete))
|
||||
.route("/api/ps5/pldmgr/autoload", post(pldmgr_api::autoload))
|
||||
.route("/api/ps5/pldmgr/store", post(pldmgr_api::store))
|
||||
.route("/api/ps5/hearbridge/status", get(hearbridge_api::status))
|
||||
.route("/api/ps5/hearbridge/saved", get(hearbridge_api::saved))
|
||||
.route("/api/ps5/hearbridge/devices", get(hearbridge_api::devices))
|
||||
.route("/api/ps5/hearbridge/action", post(hearbridge_api::action))
|
||||
.route("/api/ps5/elfldr/ensure", post(ps5_elfldr_ensure))
|
||||
.route("/api/ps5/process/kill", post(ps5_process_kill))
|
||||
.route("/api/ps5/power/control", post(ps5_power_control))
|
||||
|
||||
Reference in new issue
Block a user