Skip to content

Commit 145c26a

Browse files
committed
boot settings modal
1 parent 609e487 commit 145c26a

11 files changed

Lines changed: 1180 additions & 0 deletions

File tree

‎robot_orchestrator/audio/__init__.py‎

Whitespace-only changes.

‎robot_orchestrator/audio/volume.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import os
2+
import re
3+
import subprocess
4+
from pathlib import Path
5+
6+
DEFAULT_PULSE_SOCKET = "/tmp/pulse-socket"
7+
_PERCENT = re.compile(r"(\d+)%")
8+
9+
10+
def _env() -> dict:
11+
env = dict(os.environ)
12+
if "PULSE_SERVER" not in env and Path(DEFAULT_PULSE_SOCKET).exists():
13+
env["PULSE_SERVER"] = f"unix:{DEFAULT_PULSE_SOCKET}"
14+
return env
15+
16+
17+
def _pactl(*args: str, timeout: float = 5.0) -> subprocess.CompletedProcess:
18+
return subprocess.run(["pactl", *args], capture_output=True, text=True, timeout=timeout, env=_env())
19+
20+
21+
def get_volume() -> dict | None:
22+
try:
23+
volume = _pactl("get-sink-volume", "@DEFAULT_SINK@")
24+
mute = _pactl("get-sink-mute", "@DEFAULT_SINK@")
25+
except (OSError, subprocess.TimeoutExpired):
26+
return None
27+
if volume.returncode != 0:
28+
return None
29+
levels = [int(m) for m in _PERCENT.findall(volume.stdout)]
30+
if not levels:
31+
return None
32+
return {"percent": max(levels), "muted": mute.returncode == 0 and "yes" in mute.stdout.lower()}
33+
34+
35+
def set_volume(percent: int | None = None, muted: bool | None = None) -> dict | None:
36+
try:
37+
if percent is not None:
38+
_pactl("set-sink-volume", "@DEFAULT_SINK@", f"{max(0, min(100, int(percent)))}%")
39+
if muted is not None:
40+
_pactl("set-sink-mute", "@DEFAULT_SINK@", "1" if muted else "0")
41+
except (OSError, subprocess.TimeoutExpired):
42+
return None
43+
return get_volume()

‎robot_orchestrator/network/info.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import re
2+
import subprocess
3+
from pathlib import Path
4+
5+
6+
def _run(argv: list[str], timeout: float = 5.0) -> str:
7+
try:
8+
result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
9+
except (OSError, subprocess.TimeoutExpired):
10+
return ""
11+
return result.stdout if result.returncode == 0 else ""
12+
13+
14+
def _split_terse(line: str) -> list[str]:
15+
parts, current, escaped = [], "", False
16+
for ch in line:
17+
if escaped:
18+
current += ch
19+
escaped = False
20+
elif ch == "\\":
21+
escaped = True
22+
elif ch == ":":
23+
parts.append(current)
24+
current = ""
25+
else:
26+
current += ch
27+
parts.append(current)
28+
return parts
29+
30+
31+
def wan_route() -> dict:
32+
"""Interface and address the kernel uses to reach the internet."""
33+
out = _run(["ip", "-4", "route", "get", "1.1.1.1"])
34+
dev = re.search(r"\bdev (\S+)", out)
35+
src = re.search(r"\bsrc (\S+)", out)
36+
via = re.search(r"\bvia (\S+)", out)
37+
return {
38+
"interface": dev.group(1) if dev else None,
39+
"ip": src.group(1) if src else None,
40+
"gateway": via.group(1) if via else None,
41+
}
42+
43+
44+
def interface_kind(interface: str | None) -> str | None:
45+
if not interface:
46+
return None
47+
if Path(f"/sys/class/net/{interface}/wireless").exists():
48+
return "wifi"
49+
return "ethernet"
50+
51+
52+
def current_ssid() -> str | None:
53+
for line in _run(["nmcli", "-t", "-f", "ACTIVE,SSID", "device", "wifi"]).splitlines():
54+
fields = _split_terse(line)
55+
if len(fields) >= 2 and fields[0] == "yes":
56+
return fields[1] or None
57+
return None
58+
59+
60+
def interface_addresses() -> list[dict]:
61+
rows = []
62+
for line in _run(["ip", "-4", "-o", "addr"]).splitlines():
63+
match = re.match(r"\d+:\s+(\S+)\s+inet\s+(\S+)", line)
64+
if match and match.group(1) != "lo":
65+
rows.append({"interface": match.group(1), "ip": match.group(2).split("/")[0]})
66+
return rows
67+
68+
69+
def network_info() -> dict:
70+
wan = wan_route()
71+
kind = interface_kind(wan["interface"])
72+
return {
73+
**wan,
74+
"kind": kind,
75+
"ssid": current_ssid() if kind == "wifi" else None,
76+
"online": wan["interface"] is not None,
77+
"addresses": interface_addresses(),
78+
}

‎robot_orchestrator/network/wifi.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Callable
33

44
from robot_orchestrator.bootlog import logged_run
5+
from robot_orchestrator.network.info import _split_terse
56
from robot_orchestrator.secrets.protocol import WifiCredential
67

78
RunnerT = Callable[[list[str], float], subprocess.CompletedProcess]
@@ -31,6 +32,56 @@ def list_visible_ssids(self) -> list[str]:
3132
ssids.append(ssid)
3233
return ssids
3334

35+
def list_networks(self) -> list[dict]:
36+
try:
37+
result = self.runner(
38+
["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list"], self.timeout_s
39+
)
40+
except (OSError, subprocess.TimeoutExpired):
41+
return []
42+
if result.returncode != 0:
43+
return []
44+
networks: dict[str, dict] = {}
45+
for line in result.stdout.splitlines():
46+
fields = _split_terse(line)
47+
if len(fields) < 4 or not fields[1]:
48+
continue
49+
item = {
50+
"ssid": fields[1], "signal": int(fields[2]) if fields[2].isdigit() else 0,
51+
"secured": fields[3] not in ("", "--"), "in_use": fields[0] == "*",
52+
}
53+
known = networks.get(item["ssid"])
54+
if known is None or (item["in_use"], item["signal"]) > (known["in_use"], known["signal"]):
55+
networks[item["ssid"]] = item
56+
return sorted(networks.values(), key=lambda n: (not n["in_use"], -n["signal"]))
57+
58+
def saved_profiles(self) -> list[str]:
59+
try:
60+
result = self.runner(["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"], self.timeout_s)
61+
except (OSError, subprocess.TimeoutExpired):
62+
return []
63+
if result.returncode != 0:
64+
return []
65+
names = []
66+
for line in result.stdout.splitlines():
67+
fields = _split_terse(line)
68+
if len(fields) >= 2 and fields[1] == "802-11-wireless":
69+
names.append(fields[0])
70+
return names
71+
72+
def rescan(self) -> None:
73+
try:
74+
self.runner(["nmcli", "device", "wifi", "rescan"], self.timeout_s)
75+
except (OSError, subprocess.TimeoutExpired):
76+
pass
77+
78+
def connect_saved(self, ssid: str) -> bool:
79+
try:
80+
result = self.runner(["nmcli", "connection", "up", "id", ssid], self.timeout_s * 2)
81+
except (OSError, subprocess.TimeoutExpired):
82+
return False
83+
return result.returncode == 0
84+
3485
def connect(self, ssid: str, psk: str) -> bool:
3586
try:
3687
result = self.runner(["nmcli", "device", "wifi", "connect", ssid, "password", psk], self.timeout_s)

‎robot_orchestrator/web/app.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ def boot_state(request: Request):
157157
"operator_url": context.operator_url,
158158
}
159159

160+
@app.post("/boot/api/session", include_in_schema=False)
161+
def boot_session(request: Request):
162+
require_loopback(request)
163+
# a tunnel or reverse proxy ends up as a loopback peer too, but it always adds forwarding headers
164+
if any(h in request.headers for h in ("x-forwarded-for", "forwarded", "x-real-ip")):
165+
raise HTTPException(status_code=403, detail="direct loopback only")
166+
if not (context and context.boot_progress and context.boot_progress.finished_at):
167+
raise HTTPException(status_code=409, detail="boot is not finished yet")
168+
token = auth.issue_token(jwt_secret, "boot-settings", jwt_ttl_s)
169+
return {"access_token": token, "expires_in": jwt_ttl_s}
170+
160171
@app.get("/boot/api/stream", include_in_schema=False)
161172
async def boot_stream(request: Request):
162173
require_loopback(request)

‎robot_orchestrator/web/routes_api.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,33 @@
55
from fastapi import Depends, FastAPI, HTTPException
66
from pydantic import BaseModel
77

8+
from robot_orchestrator.audio import volume as audio_volume
9+
from robot_orchestrator.network import info as network_info
810
from robot_orchestrator.secrets import store as secrets_store
911
from robot_orchestrator.secrets.ingest import ingest_payload
1012
from robot_orchestrator.secrets.protocol import SecretsPayload
1113
from robot_orchestrator.state import store
1214
from robot_orchestrator.supervisor.logsink import parse_line
1315
from robot_orchestrator.supervisor.service import ServiceState
1416
from robot_orchestrator.web import auth
17+
from robot_orchestrator.web import voice_control
1518
from robot_orchestrator.web.context import AdminContext
1619

1720

21+
class WifiJoinRequest(BaseModel):
22+
ssid: str
23+
psk: str | None = None
24+
25+
26+
class VolumeRequest(BaseModel):
27+
percent: int | None = None
28+
muted: bool | None = None
29+
30+
31+
class WakeWordRequest(BaseModel):
32+
enabled: bool
33+
34+
1835
class ChangePasswordRequest(BaseModel):
1936
current_password: str
2037
new_password: str
@@ -248,6 +265,64 @@ async def connect_wifi(claims: dict = Depends(require_auth)):
248265
connected = await asyncio.to_thread(c.wifi_manager.try_known_networks, known)
249266
return {"connected_ssid": connected}
250267

268+
@app.get("/api/network")
269+
async def get_network(claims: dict = Depends(require_auth)):
270+
return await asyncio.to_thread(network_info.network_info)
271+
272+
@app.get("/api/wifi/networks")
273+
async def get_wifi_networks(rescan: bool = False, claims: dict = Depends(require_auth)):
274+
c = _require(context)
275+
if c.wifi_manager is None:
276+
raise HTTPException(status_code=503, detail="wifi manager is not available")
277+
if rescan:
278+
await asyncio.to_thread(c.wifi_manager.rescan)
279+
networks = await asyncio.to_thread(c.wifi_manager.list_networks)
280+
saved = await asyncio.to_thread(c.wifi_manager.saved_profiles)
281+
return {"networks": [{**n, "saved": n["ssid"] in saved} for n in networks]}
282+
283+
@app.post("/api/wifi/join")
284+
async def join_wifi(body: WifiJoinRequest, claims: dict = Depends(require_auth)):
285+
c = _require(context)
286+
if c.wifi_manager is None:
287+
raise HTTPException(status_code=503, detail="wifi manager is not available")
288+
if body.psk:
289+
connected = await asyncio.to_thread(c.wifi_manager.connect, body.ssid, body.psk)
290+
else:
291+
connected = await asyncio.to_thread(c.wifi_manager.connect_saved, body.ssid)
292+
return {"connected": connected, "ssid": body.ssid}
293+
294+
@app.get("/api/audio/volume")
295+
async def get_volume(claims: dict = Depends(require_auth)):
296+
state = await asyncio.to_thread(audio_volume.get_volume)
297+
if state is None:
298+
raise HTTPException(status_code=503, detail="audio mixer is not available")
299+
return state
300+
301+
@app.post("/api/audio/volume")
302+
async def set_volume(body: VolumeRequest, claims: dict = Depends(require_auth)):
303+
state = await asyncio.to_thread(audio_volume.set_volume, body.percent, body.muted)
304+
if state is None:
305+
raise HTTPException(status_code=503, detail="audio mixer is not available")
306+
return state
307+
308+
@app.get("/api/voice/wake-word")
309+
async def get_wake_word(claims: dict = Depends(require_auth)):
310+
c = _require(context)
311+
try:
312+
status = await asyncio.to_thread(voice_control.voice_status, c)
313+
except voice_control.VoiceControlError as e:
314+
raise HTTPException(status_code=e.status, detail=str(e))
315+
return {"connected": bool(status.get("connected")), "enabled": status.get("wake_word_enabled")}
316+
317+
@app.post("/api/voice/wake-word")
318+
async def set_wake_word(body: WakeWordRequest, claims: dict = Depends(require_auth)):
319+
c = _require(context)
320+
try:
321+
status = await asyncio.to_thread(voice_control.set_wake_word, c, body.enabled)
322+
except voice_control.VoiceControlError as e:
323+
raise HTTPException(status_code=e.status, detail=str(e))
324+
return {"connected": bool(status.get("connected")), "enabled": status.get("wake_word_enabled")}
325+
251326
@app.post("/api/settings/password")
252327
def change_password(body: ChangePasswordRequest, claims: dict = Depends(require_auth)):
253328
c = _require(context)

0 commit comments

Comments
 (0)