mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
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:
parent
a4eeba14ee
commit
3e559e1bf8
14 changed files with 712 additions and 132 deletions
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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'/>}
|
||||
|
|
|
|||
58
app/src/components/DesktopToggle.test.js
Normal file
58
app/src/components/DesktopToggle.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue