controller: Desktop start/stop toggle; rename hotspot/bluetooth actions to what they do

Add a Desktop toggle to the Controller (both UIs) that starts/stops
display-manager.service. Fail open: the unit is never enabled/disabled,
so the desktop always returns on reboot; users who want it disabled
across reboots can set the default target themselves.

Rename the hotspot and bluetooth actions to reflect their real behavior:
- enable/disable_hotspot -> start/stop_hotspot (/api/hotspot/start|stop)
- enable/disable_bluetooth -> unblock/block_bluetooth (rfkill semantics,
  /api/bluetooth/unblock|block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-29 14:57:28 -06:00 • committed by lrnselfreliance
parent a4eeba14ee
commit 3e559e1bf8
14 changed files with 712 additions and 132 deletions

View file

@ -92,12 +92,12 @@ export async function updateHotspotSettings(settings) {
return controllerFetch('/hotspot/settings', {method: 'POST', body: JSON.stringify(settings)});
}
export async function enableHotspot() {
return controllerFetch('/hotspot/enable', {method: 'POST'});
export async function startHotspot() {
return controllerFetch('/hotspot/start', {method: 'POST'});
}
export async function disableHotspot() {
return controllerFetch('/hotspot/disable', {method: 'POST'});
export async function stopHotspot() {
return controllerFetch('/hotspot/stop', {method: 'POST'});
}
export async function getThrottleStatus() {
@ -116,12 +116,24 @@ export async function getBluetoothStatus() {
return controllerFetch('/bluetooth/status');
}
export async function enableBluetooth() {
return controllerFetch('/bluetooth/enable', {method: 'POST'});
export async function unblockBluetooth() {
return controllerFetch('/bluetooth/unblock', {method: 'POST'});
}
export async function disableBluetooth() {
return controllerFetch('/bluetooth/disable', {method: 'POST'});
export async function blockBluetooth() {
return controllerFetch('/bluetooth/block', {method: 'POST'});
}
export async function getDesktopStatus() {
return controllerFetch('/desktop/status');
}
export async function startDesktop() {
return controllerFetch('/desktop/start', {method: 'POST'});
}
export async function stopDesktop() {
return controllerFetch('/desktop/stop', {method: 'POST'});
}
// --- Samba Endpoints ---

View file

@ -21,7 +21,7 @@ import {
} from 'semantic-ui-react';
import {Link, NavLink, useNavigate} from "react-router";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {useBluetooth, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useWROLMode} from "../hooks/customHooks";
import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useWROLMode} from "../hooks/customHooks";
import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts";
import {
Accordion,
@ -1019,6 +1019,49 @@ export function BluetoothToggle() {
</div>;
}
export function DesktopToggle() {
let {on, setDesktop} = useDesktop();
const [confirmOpen, setConfirmOpen] = React.useState(false);
const disabled = on === null;
const handleChange = (checked) => {
if (checked) {
setDesktop(true);
} else {
// Stopping the desktop kills any session on this WROLPi's own screen.
setConfirmOpen(true);
}
}
const handleConfirmStop = (e) => {
if (e) {
e.preventDefault()
}
setConfirmOpen(false);
setDesktop(false);
}
return <div style={{margin: '0.5em'}}>
<Confirm
open={confirmOpen}
onCancel={() => setConfirmOpen(false)}
onClose={() => setConfirmOpen(false)}
onConfirm={handleConfirmStop}
header='Stop the desktop'
content={"Anyone using this WROLPi's own screen will lose their session."
+ ' The desktop will return on the next reboot. Are you sure?'}
confirmButton='Stop'
/>
<Toggle
label='Desktop'
disabled={disabled}
checked={on === true}
onChange={handleChange}
/>
{disabled && <InfoPopup content='The Desktop is not supported on this server'/>}
</div>;
}
export function Toggle({label, checked, disabled, onChange, icon, popupContent = null, info = null}) {
// Custom toggle because Semantic UI does not handle inverted labels correctly.
const {t, inverted} = useContext(ThemeContext);
@ -1352,14 +1395,14 @@ export function HotspotStatusIcon() {
const {on, inUse, setHotspot, dockerized, hotspotSsid} = useHotspot();
const {modal: unsupportedModal, doOpen: openUnsupportedModal} =
UnsupportedModal('Unsupported', 'You cannot toggle the hotspot on this machine.');
const [disableHotspotOpen, setDisableHotspotOpen] = React.useState(false);
const [stopHotspotOpen, setStopHotspotOpen] = React.useState(false);
const [inUseOpen, setInUseOpen] = React.useState(false);
const handleConfirmDisable = (e) => {
const handleConfirmStop = (e) => {
if (e) {
e.preventDefault()
}
setDisableHotspotOpen(false);
setStopHotspotOpen(false);
setHotspot(false);
}
@ -1372,8 +1415,8 @@ export function HotspotStatusIcon() {
}
if (inUse === true) {
const content = hotspotSsid ? `Wifi device is in use for ${hotspotSsid}. Disconnect and enable hotspot?`
: 'Wifi device is in use. Disconnect and enable hotspot?'
const content = hotspotSsid ? `Wifi device is in use for ${hotspotSsid}. Disconnect and start hotspot?`
: 'Wifi device is in use. Disconnect and start hotspot?'
return <>
<Confirm open={inUseOpen}
onCancel={() => setInUseOpen(false)}
@ -1381,7 +1424,7 @@ export function HotspotStatusIcon() {
onConfirm={handleConfirmInUse}
header='Wifi is in-use'
content={content}
confirmButton='Enable Hotspot'
confirmButton='Start Hotspot'
/>
<a href='#' onClick={() => setInUseOpen(true)}>
<IconGroup size='large'>
@ -1393,15 +1436,15 @@ export function HotspotStatusIcon() {
} else if (dockerized === false && on === true) {
return <>
<Confirm
open={disableHotspotOpen}
onCancel={() => setDisableHotspotOpen(false)}
onClose={() => setDisableHotspotOpen(false)}
onConfirm={handleConfirmDisable}
header='Disable the hotspot'
open={stopHotspotOpen}
onCancel={() => setStopHotspotOpen(false)}
onClose={() => setStopHotspotOpen(false)}
onConfirm={handleConfirmStop}
header='Stop the hotspot'
content='You will be disconnected when using the hotspot. Are you sure?'
confirmButton='Disable'
confirmButton='Stop'
/>
<a href='#' onClick={() => setDisableHotspotOpen(true)}>
<a href='#' onClick={() => setStopHotspotOpen(true)}>
<IconGroup size='large'>
<Icon name='wifi' disabled={on !== true}/>
{on === false && <Icon corner name='x'/>}

View file

@ -0,0 +1,58 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {DesktopToggle} from './Common';
const mockSetDesktop = jest.fn();
let mockDesktopState = {on: true};
jest.mock('../hooks/customHooks', () => ({
...jest.requireActual('../hooks/customHooks'),
useDesktop: () => ({...mockDesktopState, setDesktop: mockSetDesktop}),
}));
describe('DesktopToggle', () => {
beforeEach(() => {
mockSetDesktop.mockClear();
mockDesktopState = {on: true};
});
it('renders checked when the desktop is running', () => {
render(<DesktopToggle/>);
expect(screen.getByTestId('toggle')).toBeChecked();
});
it('starts the desktop immediately when toggled on', async () => {
mockDesktopState = {on: false};
render(<DesktopToggle/>);
await userEvent.click(screen.getByTestId('toggle'));
expect(mockSetDesktop).toHaveBeenCalledWith(true);
});
it('asks for confirmation before stopping the desktop', async () => {
render(<DesktopToggle/>);
await userEvent.click(screen.getByTestId('toggle'));
// Not stopped yet; the Confirm modal is shown instead.
expect(mockSetDesktop).not.toHaveBeenCalled();
expect(screen.getByText('Stop the desktop')).toBeInTheDocument();
await userEvent.click(screen.getByText('Stop'));
expect(mockSetDesktop).toHaveBeenCalledWith(false);
});
it('cancelling the confirmation leaves the desktop running', async () => {
render(<DesktopToggle/>);
await userEvent.click(screen.getByTestId('toggle'));
await userEvent.click(screen.getByText('Cancel'));
expect(mockSetDesktop).not.toHaveBeenCalled();
expect(screen.getByTestId('toggle')).toBeChecked();
});
it('does nothing when the desktop is not supported', async () => {
mockDesktopState = {on: null};
render(<DesktopToggle/>);
await userEvent.click(screen.getByTestId('toggle'));
expect(mockSetDesktop).not.toHaveBeenCalled();
expect(screen.queryByText('Stop the desktop')).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
import React from 'react';
import {Checkbox, Container, Dropdown, Form, Icon, Input} from "semantic-ui-react";
import {Button, Confirm, Header, Loader, Modal, Segment, Table} from "../Theme";
import {APIButton, BluetoothToggle, DirectorySearch, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle,} from "../Common";
import {APIButton, BluetoothToggle, DesktopToggle, DirectorySearch, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle,} from "../Common";
import {useDockerized, useMediaDirectory} from "../../hooks/customHooks";
import {Media} from "../../contexts/contexts";
import {
@ -1589,6 +1589,7 @@ function AdminControlsSection() {
<HotspotToggle/>
<BluetoothToggle/>
<DesktopToggle/>
<ThrottleToggle/>
<HotspotSettingsForm/>

View file

@ -79,6 +79,7 @@ jest.mock('../../hooks/customHooks', () => ({
useDockerized: jest.fn().mockReturnValue(false),
useMediaDirectory: jest.fn().mockReturnValue('/media/wrolpi'),
useBluetooth: jest.fn().mockReturnValue({on: false, setBluetooth: jest.fn()}),
useDesktop: jest.fn().mockReturnValue({on: true, setDesktop: jest.fn()}),
useHotspot: jest.fn().mockReturnValue({on: true, setHotspot: jest.fn()}),
useThrottle: jest.fn().mockReturnValue({on: false, setThrottle: jest.fn()}),
}));
@ -89,6 +90,7 @@ jest.mock('../Common', () => {
return {
...actual,
BluetoothToggle: () => <div data-testid="bluetooth-toggle">BluetoothToggle</div>,
DesktopToggle: () => <div data-testid="desktop-toggle">DesktopToggle</div>,
HotspotToggle: () => <div data-testid="hotspot-toggle">HotspotToggle</div>,
ThrottleToggle: () => <div data-testid="throttle-toggle">ThrottleToggle</div>,
Toggle: ({label, checked, onChange}) => (
@ -174,6 +176,10 @@ describe('ControllerPage', () => {
await waitFor(() => {
expect(screen.getByText('Hardware Controls')).toBeInTheDocument();
});
expect(screen.getByTestId('hotspot-toggle')).toBeInTheDocument();
expect(screen.getByTestId('bluetooth-toggle')).toBeInTheDocument();
expect(screen.getByTestId('desktop-toggle')).toBeInTheDocument();
expect(screen.getByTestId('throttle-toggle')).toBeInTheDocument();
});
test('calls getServices on mount', async () => {

View file

@ -43,10 +43,12 @@ import {
updateDomain,
} from "../api";
import {
enableBluetooth,
disableBluetooth,
enableHotspot,
disableHotspot,
unblockBluetooth,
blockBluetooth,
startHotspot,
stopHotspot,
startDesktop,
stopDesktop,
enableThrottle,
disableThrottle,
getControllerStats,
@ -1049,9 +1051,9 @@ export const useHotspot = () => {
setOn(null);
try {
if (enable) {
await enableHotspot();
await startHotspot();
} else {
await disableHotspot();
await stopHotspot();
}
} catch (e) {
console.error('Hotspot error:', e);
@ -1191,9 +1193,9 @@ export const useBluetooth = () => {
setOn(null);
try {
if (enable) {
await enableBluetooth();
await unblockBluetooth();
} else {
await disableBluetooth();
await blockBluetooth();
}
} catch (e) {
console.error('Bluetooth error:', e);
@ -1209,6 +1211,43 @@ export const useBluetooth = () => {
return {on, setBluetooth: localSetBluetooth};
}
export const useDesktop = () => {
const [on, setOn] = useState(null);
const {status} = useContext(StatusContext);
useEffect(() => {
const desktopStatus = status?.desktop_status;
if (desktopStatus === 'on') {
setOn(true);
} else if (desktopStatus === 'off') {
setOn(false);
} else {
setOn(null);
}
}, [status?.desktop_status]);
const localSetDesktop = async (running) => {
setOn(null);
try {
if (running) {
await startDesktop();
} else {
await stopDesktop();
}
} catch (e) {
console.error('Desktop error:', e);
toast({
type: 'error',
title: 'Desktop Error',
description: e.message || 'Could not start/stop the desktop. See server logs.',
time: 5000,
});
}
}
return {on, setDesktop: localSetDesktop};
}
export const useSearchDirectories = (value) => {
const [directoryName, setDirectoryName] = useState(value ?? '');
const [directories, setDirectories] = useState(null);

View file

@ -7,6 +7,8 @@ from fastapi import APIRouter, HTTPException
from controller.api.schemas import (
BluetoothActionResponse,
BluetoothStatusResponse,
DesktopActionResponse,
DesktopStatusResponse,
HotspotActionResponse,
HotspotDevicesResponse,
HotspotProtocolsResponse,
@ -27,13 +29,11 @@ from controller.api.schemas import (
TimezoneStatusResponse,
)
from controller.lib.admin import (
disable_bluetooth,
disable_hotspot,
block_bluetooth,
disable_throttle,
enable_bluetooth,
enable_hotspot,
enable_throttle,
get_bluetooth_status_dict,
get_desktop_status_dict,
get_device_hotspot_protocols,
get_hotspot_device,
get_hotspot_password,
@ -47,6 +47,11 @@ from controller.lib.admin import (
restart_all_services,
set_timezone,
shutdown_system,
start_desktop,
start_hotspot,
stop_desktop,
stop_hotspot,
unblock_bluetooth,
update_hotspot_settings,
)
from controller.lib.config import is_docker_mode
@ -105,19 +110,19 @@ async def hotspot_settings_update(request: HotspotSettingsRequest) -> HotspotSet
protocol=result["protocol"])
@router.post("/api/hotspot/enable", response_model=HotspotActionResponse)
async def hotspot_enable() -> HotspotActionResponse:
"""Enable WiFi hotspot."""
result = enable_hotspot()
@router.post("/api/hotspot/start", response_model=HotspotActionResponse)
async def hotspot_start() -> HotspotActionResponse:
"""Start the WiFi hotspot."""
result = start_hotspot()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return HotspotActionResponse(**result)
@router.post("/api/hotspot/disable", response_model=HotspotActionResponse)
async def hotspot_disable() -> HotspotActionResponse:
"""Disable WiFi hotspot."""
result = disable_hotspot()
@router.post("/api/hotspot/stop", response_model=HotspotActionResponse)
async def hotspot_stop() -> HotspotActionResponse:
"""Stop the WiFi hotspot by turning the WiFi radio off."""
result = stop_hotspot()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return HotspotActionResponse(**result)
@ -131,24 +136,50 @@ async def bluetooth_status() -> BluetoothStatusResponse:
return BluetoothStatusResponse(**await get_bluetooth_status_dict())
@router.post("/api/bluetooth/enable", response_model=BluetoothActionResponse)
async def bluetooth_enable() -> BluetoothActionResponse:
"""Enable Bluetooth radio."""
result = await enable_bluetooth()
@router.post("/api/bluetooth/unblock", response_model=BluetoothActionResponse)
async def bluetooth_unblock() -> BluetoothActionResponse:
"""Unblock the Bluetooth radio (turn it on)."""
result = await unblock_bluetooth()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return BluetoothActionResponse(**result)
@router.post("/api/bluetooth/disable", response_model=BluetoothActionResponse)
async def bluetooth_disable() -> BluetoothActionResponse:
"""Disable Bluetooth radio."""
result = await disable_bluetooth()
@router.post("/api/bluetooth/block", response_model=BluetoothActionResponse)
async def bluetooth_block() -> BluetoothActionResponse:
"""Block the Bluetooth radio (turn it off)."""
result = await block_bluetooth()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return BluetoothActionResponse(**result)
# --- Desktop ---
@router.get("/api/desktop/status", response_model=DesktopStatusResponse)
async def desktop_status() -> DesktopStatusResponse:
"""Get graphical desktop (display manager) status."""
return DesktopStatusResponse(**await get_desktop_status_dict())
@router.post("/api/desktop/start", response_model=DesktopActionResponse)
async def desktop_start() -> DesktopActionResponse:
"""Start the graphical desktop until stopped or reboot."""
result = await start_desktop()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return DesktopActionResponse(**result)
@router.post("/api/desktop/stop", response_model=DesktopActionResponse)
async def desktop_stop() -> DesktopActionResponse:
"""Stop the graphical desktop; it returns on the next boot (fail open)."""
result = await stop_desktop()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return DesktopActionResponse(**result)
# --- Samba ---
@router.get("/api/samba/status", response_model=SambaStatusResponse)

View file

@ -190,7 +190,7 @@ class HotspotSettingsResponse(BaseModel):
class HotspotActionResponse(BaseModel):
"""Response model for hotspot enable/disable actions."""
"""Response model for hotspot start/stop actions."""
success: bool = Field(description="Whether the action succeeded")
ssid: Optional[str] = Field(default=None, description="Hotspot SSID")
@ -211,7 +211,26 @@ class BluetoothStatusResponse(BaseModel):
class BluetoothActionResponse(BaseModel):
"""Response model for Bluetooth enable/disable actions."""
"""Response model for Bluetooth block/unblock actions."""
success: bool = Field(description="Whether the action succeeded")
error: Optional[str] = Field(default=None, description="Error message if failed")
# ============================================================================
# Admin - Desktop
# ============================================================================
class DesktopStatusResponse(BaseModel):
"""Response model for /api/desktop/status endpoint."""
running: bool = Field(description="Whether the display manager is currently running")
available: bool = Field(description="Whether a display manager is installed")
reason: Optional[str] = Field(default=None, description="Reason if unavailable")
class DesktopActionResponse(BaseModel):
"""Response model for desktop start/stop actions."""
success: bool = Field(description="Whether the action succeeded")
error: Optional[str] = Field(default=None, description="Error message if failed")

View file

@ -12,8 +12,8 @@ wrol_mode) remain in the main WROLPi API at /api/status.
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from controller.lib.admin import get_bluetooth_status, get_hotspot_ssid, get_hotspot_status, get_throttle_status, \
BluetoothStatus, HotspotStatus
from controller.lib.admin import get_bluetooth_status, get_desktop_status, get_hotspot_ssid, get_hotspot_status, \
get_throttle_status, BluetoothStatus, HotspotStatus
from controller.lib.config import is_docker_mode, is_rpi, is_rpi4, is_rpi5
from controller.lib.status import (
get_cpu_status,
@ -39,6 +39,7 @@ async def get_system_info() -> dict:
"""
hotspot_status = get_hotspot_status()
bluetooth_status = await get_bluetooth_status()
desktop_status = await get_desktop_status()
throttle_status = get_throttle_status()
# Get hotspot SSID from config if hotspot is connected
@ -50,6 +51,7 @@ async def get_system_info() -> dict:
"is_rpi4": is_rpi4(),
"is_rpi5": is_rpi5(),
"bluetooth_status": bluetooth_status.name,
"desktop_status": desktop_status.name,
"hotspot_status": hotspot_status.name,
"hotspot_ssid": hotspot_ssid,
"throttle_status": throttle_status.name,

View file

@ -39,6 +39,14 @@ class BluetoothStatus(enum.Enum):
unknown = enum.auto() # Docker mode or cannot determine.
class DesktopStatus(enum.Enum):
"""Graphical desktop (display manager) status."""
on = enum.auto() # Display manager is running.
off = enum.auto() # Display manager is installed, but stopped.
unavailable = enum.auto() # No display manager installed.
unknown = enum.auto() # Docker mode or cannot determine.
class GovernorStatus(enum.Enum):
"""CPU governor status enum matching wrolpi/admin.py"""
ondemand = enum.auto()
@ -362,7 +370,7 @@ def get_hotspot_status_dict() -> dict:
# Map enum status to enabled/available flags
enabled = status == HotspotStatus.connected
# Hotspot is available even when radio is off because enable_hotspot()
# Hotspot is available even when radio is off because start_hotspot()
# turns the radio on first.
available = status != HotspotStatus.unknown
@ -381,9 +389,9 @@ def get_hotspot_status_dict() -> dict:
}
def enable_hotspot() -> dict:
def start_hotspot() -> dict:
"""
Enable the WiFi hotspot.
Start the WiFi hotspot (turn the radio on and bring up the Hotspot connection).
Returns:
dict with success status and message
@ -432,7 +440,7 @@ def enable_hotspot() -> dict:
return {"success": False, "error": f"WiFi device {device} not ready after turning radio on"}
# Create hotspot using NetworkManager
logger.info("Enabling hotspot on %s with SSID %s", device, ssid)
logger.info("Starting hotspot on %s with SSID %s", device, ssid)
result = subprocess.run(
[
"nmcli", "device", "wifi", "hotspot",
@ -446,7 +454,7 @@ def enable_hotspot() -> dict:
)
if result.returncode != 0:
logger.warning("Failed to enable hotspot: %s", result.stderr.strip())
logger.warning("Failed to start hotspot: %s", result.stderr.strip())
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
# Explicitly set the key management so a reused "Hotspot" profile does not
@ -475,7 +483,7 @@ def enable_hotspot() -> dict:
error = (modify.stderr if modify.returncode != 0 else up.stderr).strip()
if protocol == "wpa3":
# The driver likely rejected SAE; revert so the hotspot still works.
logger.warning("Failed to enable WPA3 hotspot, reverting to WPA2: %s", error)
logger.warning("Failed to start WPA3 hotspot, reverting to WPA2: %s", error)
revert_modify = subprocess.run(
["nmcli", "connection", "modify", "Hotspot",
"802-11-wireless-security.key-mgmt", "wpa-psk",
@ -499,23 +507,23 @@ def enable_hotspot() -> dict:
# WPA2 is what nmcli created the hotspot with anyway; not fatal.
logger.warning("Failed to explicitly set WPA2 on hotspot: %s", error)
logger.info("Hotspot enabled successfully on %s (%s)", device, protocol)
logger.info("Hotspot started successfully on %s (%s)", device, protocol)
return {"success": True, "ssid": ssid, "device": device}
except subprocess.TimeoutExpired:
logger.warning("Timeout enabling hotspot on %s", device)
return {"success": False, "error": "Timeout enabling hotspot"}
logger.warning("Timeout starting hotspot on %s", device)
return {"success": False, "error": "Timeout starting hotspot"}
except FileNotFoundError:
logger.warning("nmcli not found, cannot enable hotspot")
logger.warning("nmcli not found, cannot start hotspot")
return {"success": False, "error": "nmcli not found"}
except subprocess.SubprocessError as e:
logger.warning("Failed to enable hotspot: %s", e)
logger.warning("Failed to start hotspot: %s", e)
return {"success": False, "error": str(e)}
def disable_hotspot() -> dict:
def stop_hotspot() -> dict:
"""
Disable the WiFi hotspot.
Stop the WiFi hotspot by turning the WiFi radio off.
Returns:
dict with success status
@ -523,7 +531,7 @@ def disable_hotspot() -> dict:
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
logger.info("Disabling hotspot")
logger.info("Stopping hotspot (turning WiFi radio off)")
try:
# Turn off the radio to disconnect hotspot
result = subprocess.run(
@ -534,17 +542,17 @@ def disable_hotspot() -> dict:
)
# Success even if already off
logger.info("Hotspot disabled successfully")
logger.info("Hotspot stopped successfully")
return {"success": True}
except subprocess.TimeoutExpired:
logger.warning("Timeout disabling hotspot")
return {"success": False, "error": "Timeout disabling hotspot"}
logger.warning("Timeout stopping hotspot")
return {"success": False, "error": "Timeout stopping hotspot"}
except FileNotFoundError:
logger.warning("nmcli not found, cannot disable hotspot")
logger.warning("nmcli not found, cannot stop hotspot")
return {"success": False, "error": "nmcli not found"}
except subprocess.SubprocessError as e:
logger.warning("Failed to disable hotspot: %s", e)
logger.warning("Failed to stop hotspot: %s", e)
return {"success": False, "error": str(e)}
@ -624,9 +632,9 @@ async def get_bluetooth_status_dict() -> dict:
}
async def enable_bluetooth() -> dict:
async def unblock_bluetooth() -> dict:
"""
Enable Bluetooth radio using rfkill.
Unblock the Bluetooth radio using rfkill (turns it on).
Returns:
dict with success status
@ -634,7 +642,7 @@ async def enable_bluetooth() -> dict:
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
logger.info("Enabling Bluetooth radio")
logger.info("Unblocking Bluetooth radio")
try:
proc = await asyncio.create_subprocess_exec(
"/usr/sbin/rfkill", "unblock", "bluetooth",
@ -644,27 +652,27 @@ async def enable_bluetooth() -> dict:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
if proc.returncode == 0:
logger.info("Bluetooth radio enabled successfully")
logger.info("Bluetooth radio unblocked successfully")
return {"success": True}
else:
error_msg = stderr.decode().strip()
logger.warning("Failed to enable Bluetooth: %s", error_msg)
logger.warning("Failed to unblock Bluetooth: %s", error_msg)
return {"success": False, "error": error_msg or "Unknown error"}
except asyncio.TimeoutError:
logger.warning("Timeout enabling Bluetooth")
return {"success": False, "error": "Timeout enabling Bluetooth"}
logger.warning("Timeout unblocking Bluetooth")
return {"success": False, "error": "Timeout unblocking Bluetooth"}
except FileNotFoundError:
logger.warning("rfkill not found, cannot enable Bluetooth")
logger.warning("rfkill not found, cannot unblock Bluetooth")
return {"success": False, "error": "rfkill not found"}
except OSError as e:
logger.warning("Failed to enable Bluetooth: %s", e)
logger.warning("Failed to unblock Bluetooth: %s", e)
return {"success": False, "error": str(e)}
async def disable_bluetooth() -> dict:
async def block_bluetooth() -> dict:
"""
Disable Bluetooth radio using rfkill.
Block the Bluetooth radio using rfkill (turns it off).
Returns:
dict with success status
@ -672,7 +680,7 @@ async def disable_bluetooth() -> dict:
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
logger.info("Disabling Bluetooth radio")
logger.info("Blocking Bluetooth radio")
try:
proc = await asyncio.create_subprocess_exec(
"/usr/sbin/rfkill", "block", "bluetooth",
@ -682,24 +690,155 @@ async def disable_bluetooth() -> dict:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
if proc.returncode == 0:
logger.info("Bluetooth radio disabled successfully")
logger.info("Bluetooth radio blocked successfully")
return {"success": True}
else:
error_msg = stderr.decode().strip()
logger.warning("Failed to disable Bluetooth: %s", error_msg)
logger.warning("Failed to block Bluetooth: %s", error_msg)
return {"success": False, "error": error_msg or "Unknown error"}
except asyncio.TimeoutError:
logger.warning("Timeout disabling Bluetooth")
return {"success": False, "error": "Timeout disabling Bluetooth"}
logger.warning("Timeout blocking Bluetooth")
return {"success": False, "error": "Timeout blocking Bluetooth"}
except FileNotFoundError:
logger.warning("rfkill not found, cannot disable Bluetooth")
logger.warning("rfkill not found, cannot block Bluetooth")
return {"success": False, "error": "rfkill not found"}
except OSError as e:
logger.warning("Failed to disable Bluetooth: %s", e)
logger.warning("Failed to block Bluetooth: %s", e)
return {"success": False, "error": str(e)}
# --- Desktop ---
# Every display manager (lightdm on RPi OS, gdm3/sddm on Debian) provides this
# systemd alias, so the Controller does not need to know which one is installed.
DISPLAY_MANAGER_UNIT = "display-manager.service"
async def get_desktop_status() -> DesktopStatus:
"""
Get the graphical desktop status by checking the display manager's systemd unit.
Returns:
DesktopStatus enum value
"""
if is_docker_mode():
return DesktopStatus.unknown
try:
proc = await asyncio.create_subprocess_exec(
"systemctl", "show", DISPLAY_MANAGER_UNIT, "--property=LoadState,ActiveState",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
if proc.returncode != 0:
return DesktopStatus.unknown
properties = dict(
line.split("=", 1) for line in stdout.decode().strip().splitlines() if "=" in line
)
if properties.get("LoadState") != "loaded":
# "not-found" when no display manager is installed (e.g. RPi OS Lite).
return DesktopStatus.unavailable
return DesktopStatus.on if properties.get("ActiveState") == "active" else DesktopStatus.off
except asyncio.TimeoutError:
return DesktopStatus.unknown
except FileNotFoundError:
return DesktopStatus.unavailable
except OSError:
return DesktopStatus.unknown
async def get_desktop_status_dict() -> dict:
"""
Get desktop status as a dict for API responses.
Returns dict matching DesktopStatusResponse schema:
running: bool - Whether the display manager is currently running
available: bool - Whether a display manager is installed
reason: Optional[str] - Reason if unavailable
"""
status = await get_desktop_status()
running = status == DesktopStatus.on
available = status in (DesktopStatus.on, DesktopStatus.off)
reason = None
if status == DesktopStatus.unknown:
reason = "Desktop not supported or systemctl unavailable"
elif status == DesktopStatus.unavailable:
reason = "No display manager installed"
return {
"running": running,
"available": available,
"reason": reason,
}
async def _systemctl_desktop(action: str) -> dict:
"""Run `systemctl <start|stop> 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,

View file

@ -974,6 +974,7 @@
<div class="action-buttons" style="margin-bottom: 1rem;">
<button id="hotspot-btn" class="btn" onclick="toggleHotspot()" disabled>Loading...</button>
<button id="bluetooth-btn" class="btn" onclick="toggleBluetooth()" disabled>Loading...</button>
<button id="desktop-btn" class="btn" onclick="toggleDesktop()" disabled>Loading...</button>
</div>
<details style="margin-bottom: 1rem;">
<summary style="cursor: pointer;">Hotspot Settings</summary>
@ -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 = '<span class="loading"></span>';
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 = '<span class="loading"></span>';
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 = '<span class="loading"></span>';
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);
}
</script>

View file

@ -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()

View file

@ -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"]

View file

@ -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",