From 3e559e1bf8efd3571b4cac07f395e287a1530ce3 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 29 Jul 2026 14:57:28 -0600 Subject: [PATCH] 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 --- app/src/api/controller.js | 28 ++- app/src/components/Common.js | 71 ++++-- app/src/components/DesktopToggle.test.js | 58 +++++ app/src/components/admin/ControllerPage.js | 3 +- .../components/admin/ControllerPage.test.js | 6 + app/src/hooks/customHooks.js | 55 ++++- controller/api/admin.py | 71 ++++-- controller/api/schemas.py | 23 +- controller/api/status.py | 6 +- controller/lib/admin.py | 213 +++++++++++++++--- controller/templates/index.html | 64 +++++- controller/test/test_admin.py | 183 +++++++++++++-- controller/test/test_admin_api.py | 56 ++++- controller/test/test_api.py | 7 +- 14 files changed, 712 insertions(+), 132 deletions(-) create mode 100644 app/src/components/DesktopToggle.test.js diff --git a/app/src/api/controller.js b/app/src/api/controller.js index ed7522dd..5dfcb32a 100644 --- a/app/src/api/controller.js +++ b/app/src/api/controller.js @@ -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 --- diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 6a77368e..d40e2242 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -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() { ; } +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
+ 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' + /> + + {disabled && } +
; +} + 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 <> setInUseOpen(false)} @@ -1381,7 +1424,7 @@ export function HotspotStatusIcon() { onConfirm={handleConfirmInUse} header='Wifi is in-use' content={content} - confirmButton='Enable Hotspot' + confirmButton='Start Hotspot' /> setInUseOpen(true)}> @@ -1393,15 +1436,15 @@ export function HotspotStatusIcon() { } else if (dockerized === false && on === true) { return <> 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' /> - setDisableHotspotOpen(true)}> + setStopHotspotOpen(true)}> {on === false && } diff --git a/app/src/components/DesktopToggle.test.js b/app/src/components/DesktopToggle.test.js new file mode 100644 index 00000000..c1ffd806 --- /dev/null +++ b/app/src/components/DesktopToggle.test.js @@ -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(); + expect(screen.getByTestId('toggle')).toBeChecked(); + }); + + it('starts the desktop immediately when toggled on', async () => { + mockDesktopState = {on: false}; + render(); + await userEvent.click(screen.getByTestId('toggle')); + expect(mockSetDesktop).toHaveBeenCalledWith(true); + }); + + it('asks for confirmation before stopping the desktop', async () => { + render(); + 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(); + 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(); + await userEvent.click(screen.getByTestId('toggle')); + expect(mockSetDesktop).not.toHaveBeenCalled(); + expect(screen.queryByText('Stop the desktop')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/admin/ControllerPage.js b/app/src/components/admin/ControllerPage.js index 763db185..f53cb5d0 100644 --- a/app/src/components/admin/ControllerPage.js +++ b/app/src/components/admin/ControllerPage.js @@ -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() { + diff --git a/app/src/components/admin/ControllerPage.test.js b/app/src/components/admin/ControllerPage.test.js index ae374107..50a26ee5 100644 --- a/app/src/components/admin/ControllerPage.test.js +++ b/app/src/components/admin/ControllerPage.test.js @@ -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: () =>
BluetoothToggle
, + DesktopToggle: () =>
DesktopToggle
, HotspotToggle: () =>
HotspotToggle
, ThrottleToggle: () =>
ThrottleToggle
, 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 () => { diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 3aa34d9d..deccb9ce 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -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); diff --git a/controller/api/admin.py b/controller/api/admin.py index c59fd025..c8a3e36a 100644 --- a/controller/api/admin.py +++ b/controller/api/admin.py @@ -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) diff --git a/controller/api/schemas.py b/controller/api/schemas.py index d1508f8f..36f29c85 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -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") diff --git a/controller/api/status.py b/controller/api/status.py index 44137b91..294aeff9 100644 --- a/controller/api/status.py +++ b/controller/api/status.py @@ -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, diff --git a/controller/lib/admin.py b/controller/lib/admin.py index e59e4bc7..e625cb7c 100644 --- a/controller/lib/admin.py +++ b/controller/lib/admin.py @@ -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 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 @@
+
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",