display-manager.service` and report the result."""
+ if is_docker_mode():
+ return {"success": False, "error": "Not available in Docker mode"}
+
+ logger.info("Desktop %s requested (%s)", action, DISPLAY_MANAGER_UNIT)
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ "systemctl", action, DISPLAY_MANAGER_UNIT,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
+
+ if proc.returncode == 0:
+ logger.info("Desktop %s succeeded", action)
+ return {"success": True}
+ else:
+ error_msg = stderr.decode().strip()
+ logger.warning("Failed to %s desktop: %s", action, error_msg)
+ return {"success": False, "error": error_msg or "Unknown error"}
+
+ except asyncio.TimeoutError:
+ logger.warning("Timeout during desktop %s", action)
+ return {"success": False, "error": f"Timeout during desktop {action}"}
+ except FileNotFoundError:
+ logger.warning("systemctl not found, cannot %s desktop", action)
+ return {"success": False, "error": "systemctl not found"}
+ except OSError as e:
+ logger.warning("Failed to %s desktop: %s", action, e)
+ return {"success": False, "error": str(e)}
+
+
+async def start_desktop() -> dict:
+ """
+ Start the graphical desktop (display manager) until stopped or reboot.
+
+ This deliberately does not `systemctl enable` the unit; whether the desktop
+ starts on boot is left to the system's default target.
+
+ Returns:
+ dict with success status
+ """
+ return await _systemctl_desktop("start")
+
+
+async def stop_desktop() -> dict:
+ """
+ Stop the graphical desktop (display manager) until started or reboot.
+
+ Fail open: this deliberately does not `systemctl disable` the unit, so the
+ desktop returns on the next boot.
+
+ Returns:
+ dict with success status
+ """
+ return await _systemctl_desktop("stop")
+
+
GOVERNOR_MAP = {
'ondemand': GovernorStatus.ondemand,
'powersave': GovernorStatus.powersave,
diff --git a/controller/templates/index.html b/controller/templates/index.html
index 325d4483..0a9b56f8 100644
--- a/controller/templates/index.html
+++ b/controller/templates/index.html
@@ -974,6 +974,7 @@
Loading...
Loading...
+ Loading...
Hotspot Settings
@@ -1396,11 +1397,11 @@
btn.disabled = true;
btn.className = 'btn';
} else if (data.enabled) {
- btn.textContent = 'Disable Hotspot';
+ btn.textContent = 'Stop Hotspot';
btn.disabled = false;
btn.className = 'btn btn-warning';
} else {
- btn.textContent = 'Enable Hotspot';
+ btn.textContent = 'Start Hotspot';
btn.disabled = false;
btn.className = 'btn btn-primary';
}
@@ -1416,9 +1417,9 @@
btn.innerHTML = ' ';
try {
if (hotspotEnabled) {
- await apiCall('api/hotspot/disable', 'POST');
+ await apiCall('api/hotspot/stop', 'POST');
} else {
- await apiCall('api/hotspot/enable', 'POST');
+ await apiCall('api/hotspot/start', 'POST');
}
} catch (e) {
alert(`Hotspot error: ${e.message}`);
@@ -1535,9 +1536,9 @@
btn.innerHTML = ' ';
try {
if (bluetoothEnabled) {
- await apiCall('api/bluetooth/disable', 'POST');
+ await apiCall('api/bluetooth/block', 'POST');
} else {
- await apiCall('api/bluetooth/enable', 'POST');
+ await apiCall('api/bluetooth/unblock', 'POST');
}
} catch (e) {
alert(`Bluetooth error: ${e.message}`);
@@ -1545,6 +1546,55 @@
await updateBluetoothButton();
}
+ // --- Desktop ---
+ let desktopRunning = null;
+
+ async function updateDesktopButton() {
+ const btn = document.getElementById('desktop-btn');
+ if (!btn) return;
+ try {
+ const data = await apiCall('api/desktop/status');
+ desktopRunning = data.running;
+ if (!data.available) {
+ btn.textContent = 'Desktop Unavailable';
+ btn.disabled = true;
+ btn.className = 'btn';
+ } else if (data.running) {
+ btn.textContent = 'Stop Desktop';
+ btn.disabled = false;
+ btn.className = 'btn btn-warning';
+ } else {
+ btn.textContent = 'Start Desktop';
+ btn.disabled = false;
+ btn.className = 'btn btn-primary';
+ }
+ } catch (e) {
+ btn.textContent = 'Desktop Unavailable';
+ btn.disabled = true;
+ }
+ }
+
+ async function toggleDesktop() {
+ const btn = document.getElementById('desktop-btn');
+ if (desktopRunning && !confirm(
+ 'Stop the desktop? Anyone using this WROLPi\'s own screen will lose their session.'
+ + ' The desktop will return on the next reboot.')) {
+ return;
+ }
+ btn.disabled = true;
+ btn.innerHTML = ' ';
+ try {
+ if (desktopRunning) {
+ await apiCall('api/desktop/stop', 'POST');
+ } else {
+ await apiCall('api/desktop/start', 'POST');
+ }
+ } catch (e) {
+ alert(`Desktop error: ${e.message}`);
+ }
+ await updateDesktopButton();
+ }
+
async function waitForController(maxWaitMs = 60000) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
@@ -2927,6 +2977,7 @@
// Fetch hardware button status immediately
updateHotspotButton();
updateBluetoothButton();
+ updateDesktopButton();
loadHotspotSettings();
// Initial update after a short delay (to not overlap with initial load)
@@ -2945,6 +2996,7 @@
loadSmart();
updateHotspotButton();
updateBluetoothButton();
+ updateDesktopButton();
}, updateInterval);
}
diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py
index 33530533..f0a1b9b8 100644
--- a/controller/test/test_admin.py
+++ b/controller/test/test_admin.py
@@ -10,11 +10,11 @@ import yaml
from controller.lib.config import reload_config_from_drive
from controller.lib.admin import (
apply_timezone_from_config,
- disable_bluetooth,
- disable_hotspot,
+ block_bluetooth,
+ stop_hotspot,
disable_throttle,
- enable_bluetooth,
- enable_hotspot,
+ unblock_bluetooth,
+ start_hotspot,
enable_throttle,
get_bluetooth_status,
get_hotspot_status,
@@ -33,7 +33,12 @@ from controller.lib.admin import (
set_timezone,
shutdown_system,
update_hotspot_settings,
+ get_desktop_status,
+ get_desktop_status_dict,
+ start_desktop,
+ stop_desktop,
BluetoothStatus,
+ DesktopStatus,
HotspotStatus,
GovernorStatus,
)
@@ -129,13 +134,13 @@ class TestGetBluetoothStatus:
assert result == BluetoothStatus.unknown
-class TestBluetoothEnableDisable:
- """Tests for enable_bluetooth / disable_bluetooth — they share the same shape."""
+class TestBluetoothBlockUnblock:
+ """Tests for unblock_bluetooth / block_bluetooth — they share the same shape."""
@pytest.mark.asyncio
@pytest.mark.parametrize("func,rfkill_arg", [
- (enable_bluetooth, "unblock"),
- (disable_bluetooth, "block"),
+ (unblock_bluetooth, "unblock"),
+ (block_bluetooth, "block"),
])
async def test_calls_rfkill_with_correct_action(self, func, rfkill_arg):
"""Each function should invoke rfkill with the expected action."""
@@ -154,7 +159,7 @@ class TestBluetoothEnableDisable:
)
@pytest.mark.asyncio
- @pytest.mark.parametrize("func", [enable_bluetooth, disable_bluetooth])
+ @pytest.mark.parametrize("func", [unblock_bluetooth, block_bluetooth])
async def test_handles_rfkill_not_found(self, func):
"""Should handle rfkill not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
@@ -163,6 +168,132 @@ class TestBluetoothEnableDisable:
assert result["success"] is False
+def _mock_systemctl_show_proc(load_state: str, active_state: str) -> mock.AsyncMock:
+ """A mocked `systemctl show` process reporting the given unit states."""
+ mock_proc = mock.AsyncMock()
+ output = f"LoadState={load_state}\nActiveState={active_state}\n"
+ mock_proc.communicate.return_value = (output.encode(), b"")
+ mock_proc.returncode = 0
+ return mock_proc
+
+
+class TestGetDesktopStatus:
+ """Tests for get_desktop_status function."""
+
+ @pytest.mark.asyncio
+ async def test_returns_unknown_in_docker_mode(self):
+ """Should return unknown when in Docker mode."""
+ with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
+ result = await get_desktop_status()
+ assert result == DesktopStatus.unknown
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("load_state,active_state,expected", [
+ ("loaded", "active", DesktopStatus.on),
+ ("loaded", "inactive", DesktopStatus.off),
+ ("loaded", "failed", DesktopStatus.off),
+ ("not-found", "inactive", DesktopStatus.unavailable),
+ ])
+ async def test_parses_systemctl_show(self, load_state, active_state, expected):
+ """Should map the display manager's LoadState/ActiveState to a DesktopStatus."""
+ mock_proc = _mock_systemctl_show_proc(load_state, active_state)
+ with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
+ with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc):
+ result = await get_desktop_status()
+ assert result == expected
+
+ @pytest.mark.asyncio
+ async def test_returns_unavailable_when_systemctl_not_found(self):
+ """Should return unavailable when systemctl is not installed."""
+ with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
+ with mock.patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError()):
+ result = await get_desktop_status()
+ assert result == DesktopStatus.unavailable
+
+ @pytest.mark.asyncio
+ async def test_returns_unknown_when_systemctl_fails(self):
+ """Should return unknown when systemctl returns an error."""
+ mock_proc = mock.AsyncMock()
+ mock_proc.communicate.return_value = (b"", b"some error")
+ mock_proc.returncode = 1
+ with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
+ with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc):
+ result = await get_desktop_status()
+ assert result == DesktopStatus.unknown
+
+
+class TestGetDesktopStatusDict:
+ """Tests for get_desktop_status_dict function."""
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("status,running,available", [
+ (DesktopStatus.on, True, True),
+ (DesktopStatus.off, False, True),
+ (DesktopStatus.unavailable, False, False),
+ (DesktopStatus.unknown, False, False),
+ ])
+ async def test_maps_status_to_flags(self, status, running, available):
+ """Should map each DesktopStatus to running/available flags."""
+ with mock.patch("controller.lib.admin.get_desktop_status", return_value=status):
+ result = await get_desktop_status_dict()
+ assert result["running"] is running
+ assert result["available"] is available
+ if available:
+ assert result["reason"] is None
+ else:
+ assert result["reason"]
+
+
+class TestDesktopStartStop:
+ """Tests for start_desktop / stop_desktop — they share the same shape."""
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("func,systemctl_action", [
+ (start_desktop, "start"),
+ (stop_desktop, "stop"),
+ ])
+ async def test_calls_systemctl_with_correct_action(self, func, systemctl_action):
+ """Each function should invoke systemctl with the expected action; never enable/disable."""
+ mock_proc = mock.AsyncMock()
+ mock_proc.communicate.return_value = (b"", b"")
+ mock_proc.returncode = 0
+
+ with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
+ with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
+ result = await func()
+ assert result["success"] is True
+ # Fail open: only start/stop the unit, never enable/disable or
+ # change the default target.
+ mock_exec.assert_called_once_with(
+ "systemctl", systemctl_action, "display-manager.service",
+ stdout=mock.ANY,
+ stderr=mock.ANY,
+ )
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("func", [start_desktop, stop_desktop])
+ async def test_reports_systemctl_error(self, func):
+ """Should report systemctl's stderr when the action fails."""
+ mock_proc = mock.AsyncMock()
+ mock_proc.communicate.return_value = (b"", b"Unit display-manager.service not loaded.")
+ mock_proc.returncode = 5
+
+ with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
+ with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc):
+ result = await func()
+ assert result["success"] is False
+ assert "not loaded" in result["error"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("func", [start_desktop, stop_desktop])
+ async def test_handles_systemctl_not_found(self, func):
+ """Should handle systemctl not being available."""
+ with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
+ with mock.patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError()):
+ result = await func()
+ assert result["success"] is False
+
+
class TestGetHotspotStatus:
"""Tests for get_hotspot_status function."""
@@ -285,7 +416,7 @@ class TestGetHotspotStatusDict:
def test_available_when_radio_off(self):
"""Hotspot should be available when WiFi radio is off,
- because enable_hotspot() turns the radio on first."""
+ because start_hotspot() turns the radio on first."""
with mock.patch("controller.lib.admin.get_hotspot_status", return_value=HotspotStatus.off):
result = get_hotspot_status_dict()
assert result["available"] is True
@@ -617,10 +748,12 @@ class TestDockerModeErrors:
"""Admin functions should return an error result when running in Docker mode."""
@pytest.mark.parametrize("func,is_async,args", [
- (enable_bluetooth, True, ()),
- (disable_bluetooth, True, ()),
- (enable_hotspot, False, ()),
- (disable_hotspot, False, ()),
+ (unblock_bluetooth, True, ()),
+ (block_bluetooth, True, ()),
+ (start_hotspot, False, ()),
+ (stop_hotspot, False, ()),
+ (start_desktop, True, ()),
+ (stop_desktop, True, ()),
(enable_throttle, False, ()),
(disable_throttle, False, ()),
(shutdown_system, False, ()),
@@ -639,14 +772,14 @@ class TestDockerModeErrors:
assert "Docker" in error
-class TestEnableHotspot:
- """Tests for enable_hotspot function."""
+class TestStartHotspot:
+ """Tests for start_hotspot function."""
def test_handles_nmcli_not_found(self):
"""Should handle nmcli not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
- result = enable_hotspot()
+ result = start_hotspot()
assert result["success"] is False
assert "nmcli" in result.get("error", "").lower()
@@ -669,7 +802,7 @@ class TestEnableHotspot:
with mock.patch("subprocess.run",
side_effect=[radio_on, device_ready, ok, ok, ok]) as mock_subprocess:
with mock.patch("time.sleep"):
- enable_hotspot()
+ start_hotspot()
# Find the hotspot creation call (nmcli device wifi hotspot)
calls = mock_subprocess.call_args_list
@@ -682,7 +815,7 @@ class TestEnableHotspot:
@staticmethod
def _run_enable(protocol: str, results: list) -> tuple:
- """Run enable_hotspot() with the given hotspot.protocol and subprocess results."""
+ """Run start_hotspot() with the given hotspot.protocol and subprocess results."""
def mock_get_config(key, default=None):
return {'hotspot.protocol': protocol}.get(key, default)
@@ -690,7 +823,7 @@ class TestEnableHotspot:
with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config):
with mock.patch("subprocess.run", side_effect=results) as mock_subprocess:
with mock.patch("time.sleep"):
- result = enable_hotspot()
+ result = start_hotspot()
return result, mock_subprocess.call_args_list
def test_wpa2_sets_psk_key_mgmt(self):
@@ -772,7 +905,7 @@ class TestEnableHotspot:
hotspot_result, # nmcli connection up Hotspot
]):
with mock.patch("time.sleep"): # Don't actually sleep in tests
- result = enable_hotspot()
+ result = start_hotspot()
assert result["success"] is True
def test_fails_if_device_never_becomes_ready(self):
@@ -786,19 +919,19 @@ class TestEnableHotspot:
radio_on_result,
] + [device_unavailable] * 20):
with mock.patch("time.sleep"):
- result = enable_hotspot()
+ result = start_hotspot()
assert result["success"] is False
assert "not ready" in result.get("error", "").lower() or "unavailable" in result.get("error", "").lower()
-class TestDisableHotspot:
- """Tests for disable_hotspot function."""
+class TestStopHotspot:
+ """Tests for stop_hotspot function."""
def test_handles_nmcli_not_found(self):
"""Should handle nmcli not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
- result = disable_hotspot()
+ result = stop_hotspot()
assert result["success"] is False
assert "nmcli" in result.get("error", "").lower()
diff --git a/controller/test/test_admin_api.py b/controller/test/test_admin_api.py
index 843d6517..256f7005 100644
--- a/controller/test/test_admin_api.py
+++ b/controller/test/test_admin_api.py
@@ -6,11 +6,12 @@ import pytest
class TestStatusEndpoints:
- """Status endpoints (hotspot/bluetooth/throttle/timezone) — shape + docker behaviour."""
+ """Status endpoints (hotspot/bluetooth/desktop/throttle/timezone) — shape + docker behaviour."""
@pytest.mark.parametrize("endpoint,required_fields", [
("/api/hotspot/status", ["enabled", "available"]),
("/api/bluetooth/status", ["enabled", "available"]),
+ ("/api/desktop/status", ["running", "available"]),
("/api/throttle/status", ["enabled", "available"]),
("/api/timezone/status", ["available", "timezone"]),
])
@@ -25,6 +26,7 @@ class TestStatusEndpoints:
@pytest.mark.parametrize("endpoint", [
"/api/hotspot/status",
"/api/bluetooth/status",
+ "/api/desktop/status",
"/api/throttle/status",
"/api/timezone/status",
])
@@ -122,11 +124,13 @@ class TestDockerModeRejectsAdminActions:
"""Admin action endpoints should reject requests when running in Docker."""
@pytest.mark.parametrize("method,endpoint,expected_status,payload", [
- # Subsystem enable/disable — return 500 with a Docker-flavoured error.
- ("post", "/api/hotspot/enable", 500, None),
- ("post", "/api/hotspot/disable", 500, None),
- ("post", "/api/bluetooth/enable", 500, None),
- ("post", "/api/bluetooth/disable", 500, None),
+ # Subsystem actions — return 500 with a Docker-flavoured error.
+ ("post", "/api/hotspot/start", 500, None),
+ ("post", "/api/hotspot/stop", 500, None),
+ ("post", "/api/bluetooth/unblock", 500, None),
+ ("post", "/api/bluetooth/block", 500, None),
+ ("post", "/api/desktop/start", 500, None),
+ ("post", "/api/desktop/stop", 500, None),
("post", "/api/throttle/enable", 500, None),
("post", "/api/throttle/disable", 500, None),
("post", "/api/timezone/set", 500, {"timezone": "America/Denver"}),
@@ -144,3 +148,43 @@ class TestDockerModeRejectsAdminActions:
# OpenAPI endpoint-presence tests are consolidated in test_api.py::TestOpenAPI.
+
+
+class TestDesktopEndpoints:
+ """Desktop start/stop endpoints drive the display-manager.service unit."""
+
+ @pytest.mark.parametrize("endpoint,systemctl_action", [
+ ("/api/desktop/start", "start"),
+ ("/api/desktop/stop", "stop"),
+ ])
+ def test_action_runs_systemctl(self, test_client, endpoint, systemctl_action):
+ """Each action endpoint should run systemctl with the matching action."""
+ from unittest import mock
+
+ mock_proc = mock.AsyncMock()
+ mock_proc.communicate.return_value = (b"", b"")
+ mock_proc.returncode = 0
+
+ with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
+ response = test_client.post(endpoint)
+ assert response.status_code == 200
+ assert response.json()["success"] is True
+ mock_exec.assert_called_once_with(
+ "systemctl", systemctl_action, "display-manager.service",
+ stdout=mock.ANY,
+ stderr=mock.ANY,
+ )
+
+ @pytest.mark.parametrize("endpoint", ["/api/desktop/start", "/api/desktop/stop"])
+ def test_action_reports_failure(self, test_client, endpoint):
+ """A failed systemctl action should return a 500 with the error detail."""
+ from unittest import mock
+
+ mock_proc = mock.AsyncMock()
+ mock_proc.communicate.return_value = (b"", b"Failed to connect to bus")
+ mock_proc.returncode = 1
+
+ with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc):
+ response = test_client.post(endpoint)
+ assert response.status_code == 500
+ assert "Failed to connect to bus" in response.json()["detail"]
diff --git a/controller/test/test_api.py b/controller/test/test_api.py
index 83a2f586..e34a0d51 100644
--- a/controller/test/test_api.py
+++ b/controller/test/test_api.py
@@ -105,9 +105,10 @@ class TestOpenAPI:
# Stats (per-resource + aggregated).
"/api/stats/cpu", "/api/stats/memory", "/api/stats/load",
"/api/stats/drives/primary", "/api/stats/network", "/api/stats/power",
- # Admin: hotspot, bluetooth, throttle, timezone, power.
- "/api/hotspot/status", "/api/hotspot/enable", "/api/hotspot/disable",
- "/api/bluetooth/status", "/api/bluetooth/enable", "/api/bluetooth/disable",
+ # Admin: hotspot, bluetooth, desktop, throttle, timezone, power.
+ "/api/hotspot/status", "/api/hotspot/start", "/api/hotspot/stop",
+ "/api/bluetooth/status", "/api/bluetooth/unblock", "/api/bluetooth/block",
+ "/api/desktop/status", "/api/desktop/start", "/api/desktop/stop",
"/api/throttle/status", "/api/throttle/enable", "/api/throttle/disable",
"/api/timezone/status", "/api/timezone/set",
"/api/shutdown", "/api/reboot", "/api/restart",