diff --git a/app/src/components/Common.js b/app/src/components/Common.js
index 6e2ed99b..d1adf38d 100644
--- a/app/src/components/Common.js
+++ b/app/src/components/Common.js
@@ -977,117 +977,120 @@ export function HelpHeader({
}
-export function HotspotToggle() {
- let {on, setHotspot} = useHotspot();
- const disabled = on === null;
- return
- setHotspot(checked)}
- />
- {disabled && }
-
;
-}
-
-export function ThrottleToggle() {
- let {on, setThrottle} = useThrottle();
- const disabled = on === null;
- return
- setThrottle(checked)}
- />
- {disabled && }
-
;
-}
-
-export function BluetoothToggle() {
- let {on, setBluetooth} = useBluetooth();
- const disabled = on === null;
- return
- setBluetooth(checked)}
- />
- {disabled && }
-
;
-}
-
-export function DesktopToggle() {
- let {on, setDesktop} = useDesktop();
+// All the hardware toggles share this shape: a Toggle whose state comes from a
+// subsystem hook, disabled when the status is unknown, with an InfoPopup explaining
+// why it cannot be used. `confirmStop` asks before switching off.
+function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, disabled = null, confirmStop = null}) {
const [confirmOpen, setConfirmOpen] = React.useState(false);
- const disabled = on === null;
+ const unsupported = on === null;
const handleChange = (checked) => {
- if (checked) {
- setDesktop(true);
- } else {
- // Stopping the desktop kills any session on this WROLPi's own screen.
+ if (!checked && confirmStop) {
setConfirmOpen(true);
+ } else {
+ onChange(checked);
}
}
- const handleConfirmStop = (e) => {
+ const handleConfirm = (e) => {
if (e) {
e.preventDefault()
}
setConfirmOpen(false);
- setDesktop(false);
+ onChange(false);
}
+ const popup = unsupported ? unsupportedMessage : info;
+
return
- setConfirmOpen(false)}
onClose={() => setConfirmOpen(false)}
- onConfirm={handleConfirmStop}
- header='Stop the desktop'
- content={"Anyone using this WROLPi's own screen will lose their session and the display will"
- + ' drop to a terminal. The desktop will return on the next reboot. Are you sure?'}
- confirmButton='Stop'
- />
+ onConfirm={handleConfirm}
+ header={confirmStop.header}
+ content={confirmStop.content}
+ confirmButton={confirmStop.button}
+ />}
- {disabled && }
+ {popup && }
;
}
+export function HotspotToggle() {
+ const {on, setHotspot} = useHotspot();
+ return ;
+}
+
+export function ThrottleToggle() {
+ const {on, setThrottle} = useThrottle();
+ return ;
+}
+
+export function BluetoothToggle() {
+ const {on, setBluetooth} = useBluetooth();
+ return ;
+}
+
+export function DesktopToggle() {
+ const {on, setDesktop} = useDesktop();
+ return ;
+}
+
export function VncToggle() {
- let {on, desktopRunning, setVnc} = useVnc();
+ const {on, desktopRunning, setVnc} = useVnc();
const unsupported = on === null;
// VNC serves the desktop session; it cannot be started without one. Stopping
// stays available so a running VNC server is never stranded on.
const blockedByDesktop = !desktopRunning && on !== true;
- const disabled = unsupported || blockedByDesktop;
let info = null;
- if (unsupported) {
- info = 'VNC is not supported on this server';
- } else if (blockedByDesktop) {
+ if (blockedByDesktop) {
info = 'Start the Desktop before starting VNC';
} else if (on === true && !desktopRunning) {
info = 'The Desktop is stopped, so VNC clients will see nothing';
}
- return
- setVnc(checked)}
- />
- {info && }
-
;
+ return ;
}
export function Toggle({label, checked, disabled, onChange, icon, popupContent = null, info = null}) {
diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js
index d3913e19..1f8b0bf1 100644
--- a/app/src/hooks/customHooks.js
+++ b/app/src/hooks/customHooks.js
@@ -1024,276 +1024,140 @@ export const useBrowseFiles = () => {
return {browseFiles, openFolders, setOpenFolders, fetchFiles};
}
-export const useHotspot = () => {
+// Every hardware toggle works the same way: one field of the shared status object
+// says whether the subsystem is on, and starting/stopping it is a Controller call.
+// `on` is null when the status is unknown, which the UI renders as unsupported.
+const useSubsystemToggle = ({
+ statusField,
+ onValue = 'on',
+ offValues = ['off'],
+ start,
+ stop,
+ errorTitle,
+ errorDescription,
+ refetchStatus = false,
+ }) => {
const [on, setOn] = useState(null);
- const [inUse, setInUse] = useState(false);
- const {status} = useContext(StatusContext);
- // Hotspot is unsupported on Docker.
- const {dockerized, hotspot_ssid} = status;
+ const {status, fetchStatus} = useContext(StatusContext);
+ const value = status?.[statusField];
useEffect(() => {
- if (status && status['hotspot_status']) {
- const {hotspot_status} = status;
- if (hotspot_status === 'connected') {
- setOn(true);
- setInUse(false);
- } else if (hotspot_status === 'in_use') {
- setInUse(true);
- setOn(false);
- } else if (hotspot_status === 'disconnected' || hotspot_status === 'off') {
- setOn(false);
- setInUse(false);
- } else {
- setOn(null);
- }
+ if (value === onValue) {
+ setOn(true);
+ } else if (offValues.includes(value)) {
+ setOn(false);
+ } else {
+ setOn(null);
}
- }, [status]);
+ }, [value]);
- const localSetHotspot = async (enable) => {
+ const setSubsystem = async (enable) => {
+ const previous = on;
setOn(null);
try {
if (enable) {
- await startHotspot();
+ await start();
} else {
- await stopHotspot();
+ await stop();
}
} catch (e) {
- console.error('Hotspot error:', e);
+ console.error(`${errorTitle}:`, e);
toast({
type: 'error',
- title: 'Hotspot Error',
- description: e.message || 'Could not modify hotspot. See server logs.',
+ title: errorTitle,
+ description: e.message || errorDescription,
time: 5000,
});
+ // Nothing changed, so the status poll will not re-sync `on`; without this
+ // the toggle stays disabled and reads as unsupported until it remounts.
+ setOn(previous);
+ }
+ if (refetchStatus) {
+ await fetchStatus();
}
}
- return {on, inUse, hotspotSsid: hotspot_ssid, setOn, setHotspot: localSetHotspot, dockerized};
+ return {on, setOn, setSubsystem};
}
-export const useDownloads = () => {
- const [onceDownloads, setOnceDownloads] = useState(null);
- const [recurringDownloads, setRecurringDownloads] = useState(null);
- const [pendingOnceDownloads, setPendingOnceDownloads] = useState(null);
+export const useHotspot = () => {
+ const {status} = useContext(StatusContext);
+ // Hotspot is unsupported on Docker.
+ const {dockerized, hotspot_ssid, hotspot_status} = status;
+ const {on, setOn, setSubsystem} = useSubsystemToggle({
+ statusField: 'hotspot_status',
+ onValue: 'connected',
+ // The WiFi device being in use for a network is a kind of "off": the hotspot
+ // is not running, but starting it takes the user off that network.
+ offValues: ['disconnected', 'off', 'in_use'],
+ start: startHotspot,
+ stop: stopHotspot,
+ errorTitle: 'Hotspot Error',
+ errorDescription: 'Could not modify hotspot. See server logs.',
+ });
- const fetchDownloads = async () => {
- try {
- const data = await getDownloads();
- setOnceDownloads(data['once_downloads']);
- setRecurringDownloads(data['recurring_downloads']);
- setPendingOnceDownloads(data['pending_once_downloads']);
- } catch (e) {
- console.error(e);
- // Display errors.
- setOnceDownloads(undefined);
- setRecurringDownloads(undefined);
- setPendingOnceDownloads(undefined);
- }
- }
-
- useRecurringTimeout(fetchDownloads, 3 * 1000);
-
- return {onceDownloads, recurringDownloads, pendingOnceDownloads, fetchDownloads}
-}
-
-export const useConfigs = () => {
- const [configs, setConfigs] = useState(null);
- const [loading, setLoading] = useState(false);
-
- const fetchConfigs = async () => {
- setLoading(true);
- console.log('fetching configs...');
- try {
- const data = await getConfigs();
- setConfigs(data['configs']);
- console.debug('fetching configs successful');
- } catch (e) {
- // Ignore SyntaxError because they happen when the API is down.
- if (!(e instanceof SyntaxError)) {
- console.error(e);
- }
- setConfigs(undefined);
- } finally {
- setLoading(false);
- }
- }
-
- const importConfig = async (fileName) => {
- try {
- await postImportConfig(fileName);
- } catch (e) {
- console.error(e);
- }
- }
-
- const saveConfig = async (fileName) => {
- try {
- await postDumpConfig(fileName);
- } catch (e) {
- console.error(e);
- }
- }
-
- return {configs, loading, fetchConfigs, importConfig, saveConfig}
+ return {
+ on,
+ inUse: hotspot_status === 'in_use',
+ hotspotSsid: hotspot_ssid,
+ setOn,
+ setHotspot: setSubsystem,
+ dockerized,
+ };
}
export const useThrottle = () => {
- const [on, setOn] = useState(null);
- const {status, fetchStatus} = React.useContext(StatusContext);
+ const {on, setOn, setSubsystem} = useSubsystemToggle({
+ statusField: 'throttle_status',
+ onValue: 'powersave',
+ offValues: ['ondemand'],
+ start: enableThrottle,
+ stop: disableThrottle,
+ errorTitle: 'Throttle Error',
+ errorDescription: 'Could not modify throttle. See server logs.',
+ // The governor is read from sysfs, so refetch rather than wait for the poll.
+ refetchStatus: true,
+ });
- useEffect(() => {
- const throttleStatus = status?.throttle_status;
- if (throttleStatus === 'powersave') {
- setOn(true);
- } else if (throttleStatus === 'ondemand') {
- setOn(false);
- } else {
- setOn(null);
- }
- }, [status?.throttle_status]);
-
- const localSetThrottle = async (enable) => {
- setOn(null);
- try {
- if (enable) {
- await enableThrottle();
- } else {
- await disableThrottle();
- }
- } catch (e) {
- console.error('Throttle error:', e);
- toast({
- type: 'error',
- title: 'Throttle Error',
- description: e.message || 'Could not modify throttle. See server logs.',
- time: 5000,
- });
- }
- // Refetch status to get updated throttle state
- await fetchStatus();
- }
-
- return {on, setOn, setThrottle: localSetThrottle};
+ return {on, setOn, setThrottle: setSubsystem};
}
export const useBluetooth = () => {
- const [on, setOn] = useState(null);
- const {status} = useContext(StatusContext);
+ const {on, setSubsystem} = useSubsystemToggle({
+ statusField: 'bluetooth_status',
+ start: unblockBluetooth,
+ stop: blockBluetooth,
+ errorTitle: 'Bluetooth Error',
+ errorDescription: 'Could not modify Bluetooth. See server logs.',
+ });
- useEffect(() => {
- const bluetoothStatus = status?.bluetooth_status;
- if (bluetoothStatus === 'on') {
- setOn(true);
- } else if (bluetoothStatus === 'off') {
- setOn(false);
- } else {
- setOn(null);
- }
- }, [status?.bluetooth_status]);
-
- const localSetBluetooth = async (enable) => {
- setOn(null);
- try {
- if (enable) {
- await unblockBluetooth();
- } else {
- await blockBluetooth();
- }
- } catch (e) {
- console.error('Bluetooth error:', e);
- toast({
- type: 'error',
- title: 'Bluetooth Error',
- description: e.message || 'Could not modify Bluetooth. See server logs.',
- time: 5000,
- });
- }
- }
-
- return {on, setBluetooth: localSetBluetooth};
+ return {on, setBluetooth: setSubsystem};
}
export const useDesktop = () => {
- const [on, setOn] = useState(null);
- const {status} = useContext(StatusContext);
+ const {on, setSubsystem} = useSubsystemToggle({
+ statusField: 'desktop_status',
+ start: startDesktop,
+ stop: stopDesktop,
+ errorTitle: 'Desktop Error',
+ errorDescription: 'Could not start/stop the desktop. See server logs.',
+ });
- 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) => {
- const previous = on;
- 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,
- });
- // The desktop did not change, so the status poll will not re-sync `on`;
- // restore it or the toggle stays disabled.
- setOn(previous);
- }
- }
-
- return {on, setDesktop: localSetDesktop};
+ return {on, setDesktop: setSubsystem};
}
export const useVnc = () => {
- const [on, setOn] = useState(null);
const {status} = useContext(StatusContext);
+ const {on, setSubsystem} = useSubsystemToggle({
+ statusField: 'vnc_status',
+ start: startVnc,
+ stop: stopVnc,
+ errorTitle: 'VNC Error',
+ errorDescription: 'Could not start/stop VNC. See server logs.',
+ });
+
// VNC serves the desktop session, so it cannot be started without one.
- const desktopRunning = status?.desktop_status === 'on';
-
- useEffect(() => {
- const vncStatus = status?.vnc_status;
- if (vncStatus === 'on') {
- setOn(true);
- } else if (vncStatus === 'off') {
- setOn(false);
- } else {
- setOn(null);
- }
- }, [status?.vnc_status]);
-
- const localSetVnc = async (running) => {
- const previous = on;
- setOn(null);
- try {
- if (running) {
- await startVnc();
- } else {
- await stopVnc();
- }
- } catch (e) {
- console.error('VNC error:', e);
- toast({
- type: 'error',
- title: 'VNC Error',
- description: e.message || 'Could not start/stop VNC. See server logs.',
- time: 5000,
- });
- // The status poll will not re-sync `on` because VNC did not change.
- setOn(previous);
- }
- }
-
- return {on, desktopRunning, setVnc: localSetVnc};
+ return {on, desktopRunning: status?.desktop_status === 'on', setVnc: setSubsystem};
}
export const useSearchDirectories = (value) => {
diff --git a/app/src/hooks/useSubsystemToggle.test.js b/app/src/hooks/useSubsystemToggle.test.js
new file mode 100644
index 00000000..d08c241f
--- /dev/null
+++ b/app/src/hooks/useSubsystemToggle.test.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import {act, renderHook, waitFor} from '@testing-library/react';
+import {useBluetooth, useDesktop, useHotspot, useThrottle, useVnc} from './customHooks';
+import {StatusContext} from '../contexts/contexts';
+
+jest.mock('../api/controller', () => ({
+ unblockBluetooth: jest.fn().mockResolvedValue({}),
+ blockBluetooth: jest.fn().mockResolvedValue({}),
+ startHotspot: jest.fn().mockResolvedValue({}),
+ stopHotspot: jest.fn().mockResolvedValue({}),
+ startDesktop: jest.fn().mockResolvedValue({}),
+ stopDesktop: jest.fn().mockResolvedValue({}),
+ startVnc: jest.fn().mockResolvedValue({}),
+ stopVnc: jest.fn().mockResolvedValue({}),
+ enableThrottle: jest.fn().mockResolvedValue({}),
+ disableThrottle: jest.fn().mockResolvedValue({}),
+ getControllerStats: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('react-semantic-toasts-2', () => ({toast: jest.fn()}));
+
+const controllerApi = require('../api/controller');
+
+const fetchStatus = jest.fn();
+
+const makeWrapper = (status) => ({children}) => (
+ {children}
+);
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ Object.values(controllerApi).forEach(fn => fn.mockResolvedValue && fn.mockResolvedValue({}));
+});
+
+describe('subsystem toggle hooks', () => {
+ // Each hook reads a different status field with different values for on/off.
+ const cases = [
+ {
+ name: 'bluetooth', hook: useBluetooth, setter: 'setBluetooth',
+ status: {bluetooth_status: 'on'}, offStatus: {bluetooth_status: 'off'},
+ start: 'unblockBluetooth', stop: 'blockBluetooth',
+ },
+ {
+ name: 'desktop', hook: useDesktop, setter: 'setDesktop',
+ status: {desktop_status: 'on'}, offStatus: {desktop_status: 'off'},
+ start: 'startDesktop', stop: 'stopDesktop',
+ },
+ {
+ name: 'vnc', hook: useVnc, setter: 'setVnc',
+ status: {vnc_status: 'on'}, offStatus: {vnc_status: 'off'},
+ start: 'startVnc', stop: 'stopVnc',
+ },
+ {
+ name: 'throttle', hook: useThrottle, setter: 'setThrottle',
+ status: {throttle_status: 'powersave'}, offStatus: {throttle_status: 'ondemand'},
+ start: 'enableThrottle', stop: 'disableThrottle',
+ },
+ {
+ name: 'hotspot', hook: useHotspot, setter: 'setHotspot',
+ status: {hotspot_status: 'connected'}, offStatus: {hotspot_status: 'disconnected'},
+ start: 'startHotspot', stop: 'stopHotspot',
+ },
+ ];
+
+ cases.forEach(({name, hook, setter, status, offStatus, start, stop}) => {
+ describe(name, () => {
+ it('reports on and off from the status field', async () => {
+ const {result} = renderHook(hook, {wrapper: makeWrapper(status)});
+ await waitFor(() => expect(result.current.on).toBe(true));
+
+ const {result: offResult} = renderHook(hook, {wrapper: makeWrapper(offStatus)});
+ await waitFor(() => expect(offResult.current.on).toBe(false));
+ });
+
+ it('reports unknown status as null so the UI can disable the toggle', async () => {
+ const {result} = renderHook(hook, {wrapper: makeWrapper({})});
+ await waitFor(() => expect(result.current.on).toBe(null));
+ });
+
+ it('starts and stops through the Controller', async () => {
+ const {result} = renderHook(hook, {wrapper: makeWrapper(offStatus)});
+ await waitFor(() => expect(result.current.on).toBe(false));
+
+ await act(async () => await result.current[setter](true));
+ expect(controllerApi[start]).toHaveBeenCalled();
+
+ await act(async () => await result.current[setter](false));
+ expect(controllerApi[stop]).toHaveBeenCalled();
+ });
+
+ it('restores the previous state when the request fails', async () => {
+ // Otherwise `on` stays null, and because the status poll sees no change
+ // the toggle is left disabled and reading as unsupported until remount.
+ controllerApi[stop].mockRejectedValue(new Error('boom'));
+ const {result} = renderHook(hook, {wrapper: makeWrapper(status)});
+ await waitFor(() => expect(result.current.on).toBe(true));
+
+ await act(async () => await result.current[setter](false));
+
+ expect(result.current.on).toBe(true);
+ });
+ });
+ });
+
+ it('throttle refetches status because the governor is read from sysfs', async () => {
+ const {result} = renderHook(useThrottle, {wrapper: makeWrapper({throttle_status: 'ondemand'})});
+ await waitFor(() => expect(result.current.on).toBe(false));
+
+ await act(async () => await result.current.setThrottle(true));
+
+ expect(fetchStatus).toHaveBeenCalled();
+ });
+
+ it('hotspot reports a WiFi device that is in use for a network', async () => {
+ const {result} = renderHook(useHotspot, {wrapper: makeWrapper({hotspot_status: 'in_use'})});
+
+ await waitFor(() => expect(result.current.inUse).toBe(true));
+ // In use means the hotspot is not running, but starting it leaves that network.
+ expect(result.current.on).toBe(false);
+ });
+
+ it('vnc exposes whether the desktop it serves is running', async () => {
+ const {result} = renderHook(useVnc, {wrapper: makeWrapper({vnc_status: 'off', desktop_status: 'on'})});
+ await waitFor(() => expect(result.current.desktopRunning).toBe(true));
+
+ const {result: stopped} = renderHook(useVnc, {wrapper: makeWrapper({vnc_status: 'off', desktop_status: 'off'})});
+ await waitFor(() => expect(stopped.current.desktopRunning).toBe(false));
+ });
+});
diff --git a/controller/api/admin.py b/controller/api/admin.py
index 6d54516f..2839df3f 100644
--- a/controller/api/admin.py
+++ b/controller/api/admin.py
@@ -69,6 +69,20 @@ from controller.lib.samba import (
router = APIRouter(tags=["admin"])
+def _raise_for_action(result: dict):
+ """
+ Turn a failed subsystem action into an HTTPException.
+
+ Actions all report `{"success": bool, "error": str}`. A precondition failure
+ (VNC needs a running desktop) is a 409 so callers can tell it from a command
+ that was attempted and failed.
+ """
+ if result.get("success"):
+ return
+ status_code = 409 if result.get("precondition_failed") else 500
+ raise HTTPException(status_code=status_code, detail=result.get("error", "Failed"))
+
+
# --- Hotspot ---
@router.get("/api/hotspot/status", response_model=HotspotStatusResponse)
@@ -119,8 +133,7 @@ async def hotspot_settings_update(request: HotspotSettingsRequest) -> HotspotSet
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"))
+ _raise_for_action(result)
return HotspotActionResponse(**result)
@@ -128,8 +141,7 @@ async def hotspot_start() -> 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"))
+ _raise_for_action(result)
return HotspotActionResponse(**result)
@@ -145,8 +157,7 @@ async def bluetooth_status() -> BluetoothStatusResponse:
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"))
+ _raise_for_action(result)
return BluetoothActionResponse(**result)
@@ -154,8 +165,7 @@ async def bluetooth_unblock() -> 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"))
+ _raise_for_action(result)
return BluetoothActionResponse(**result)
@@ -171,8 +181,7 @@ async def desktop_status() -> DesktopStatusResponse:
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"))
+ _raise_for_action(result)
return DesktopActionResponse(**result)
@@ -180,8 +189,7 @@ async def desktop_start() -> 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"))
+ _raise_for_action(result)
return DesktopActionResponse(**result)
@@ -197,10 +205,7 @@ async def vnc_status() -> VncStatusResponse:
async def vnc_start() -> VncActionResponse:
"""Start the VNC server; requires a running desktop."""
result = await start_vnc()
- if not result.get("success"):
- # 409 distinguishes "the desktop is not running" from a systemctl failure.
- status_code = 409 if result.get("precondition_failed") else 500
- raise HTTPException(status_code=status_code, detail=result.get("error", "Failed"))
+ _raise_for_action(result)
return VncActionResponse(success=True)
@@ -208,8 +213,7 @@ async def vnc_start() -> VncActionResponse:
async def vnc_stop() -> VncActionResponse:
"""Stop the VNC server; allowed even when the desktop is stopped."""
result = await stop_vnc()
- if not result.get("success"):
- raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
+ _raise_for_action(result)
return VncActionResponse(success=True)
@@ -251,8 +255,7 @@ async def throttle_status() -> ThrottleStatusResponse:
async def throttle_enable() -> ThrottleActionResponse:
"""Enable CPU throttle (powersave mode)."""
result = enable_throttle()
- if not result.get("success"):
- raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
+ _raise_for_action(result)
return ThrottleActionResponse(**result)
@@ -260,8 +263,7 @@ async def throttle_enable() -> ThrottleActionResponse:
async def throttle_disable() -> ThrottleActionResponse:
"""Disable CPU throttle (normal performance)."""
result = disable_throttle()
- if not result.get("success"):
- raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
+ _raise_for_action(result)
return ThrottleActionResponse(**result)
@@ -277,8 +279,7 @@ async def timezone_status() -> TimezoneStatusResponse:
async def timezone_set(request: TimezoneSetRequest) -> TimezoneSetResponse:
"""Set the system timezone."""
result = set_timezone(request.timezone)
- if not result.get("success"):
- raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
+ _raise_for_action(result)
return TimezoneSetResponse(**result)
@@ -294,8 +295,7 @@ async def system_shutdown() -> SystemActionResponse:
)
result = shutdown_system()
- if not result.get("success"):
- raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
+ _raise_for_action(result)
return SystemActionResponse(**result)
@@ -309,8 +309,7 @@ async def system_reboot() -> SystemActionResponse:
)
result = reboot_system()
- if not result.get("success"):
- raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
+ _raise_for_action(result)
return SystemActionResponse(**result)
diff --git a/controller/templates/index.html b/controller/templates/index.html
index 49a33273..2aa07af3 100644
--- a/controller/templates/index.html
+++ b/controller/templates/index.html
@@ -972,10 +972,10 @@
System Actions
{% if not docker_mode %}
-
-
-
-
+
+
+
+
Hotspot Settings
@@ -1384,48 +1384,101 @@
}
}
- // --- Hotspot ---
- let hotspotEnabled = null;
+ // --- Hardware toggle buttons ---
+ // The Hotspot, Bluetooth, Desktop and VNC buttons all behave the same way: read a
+ // status endpoint, label the button from it, and POST start/stop when clicked.
+ const TOGGLE_BUTTONS = {
+ hotspot: {
+ id: 'hotspot-btn', label: 'Hotspot',
+ statusUrl: 'api/hotspot/status', startUrl: 'api/hotspot/start', stopUrl: 'api/hotspot/stop',
+ isOn: data => data.enabled,
+ },
+ bluetooth: {
+ id: 'bluetooth-btn', label: 'Bluetooth',
+ statusUrl: 'api/bluetooth/status', startUrl: 'api/bluetooth/unblock', stopUrl: 'api/bluetooth/block',
+ isOn: data => data.enabled,
+ startText: 'Enable Bluetooth', stopText: 'Disable Bluetooth',
+ },
+ desktop: {
+ id: 'desktop-btn', label: 'Desktop',
+ statusUrl: 'api/desktop/status', startUrl: 'api/desktop/start', stopUrl: 'api/desktop/stop',
+ isOn: data => data.running,
+ confirmStop: 'Stop the desktop? Anyone using this WROLPi\'s own screen will lose their session'
+ + ' and the display will drop to a terminal. The desktop will return on the next reboot.',
+ // Starting or stopping the desktop changes whether VNC can run.
+ alsoRefresh: ['vnc'],
+ },
+ vnc: {
+ id: 'vnc-btn', label: 'VNC',
+ statusUrl: 'api/vnc/status', startUrl: 'api/vnc/start', stopUrl: 'api/vnc/stop',
+ isOn: data => data.running,
+ // VNC serves the desktop session, so it cannot be started without one.
+ canStart: data => data.can_start,
+ onTitle: data => data.desktop_running ? '' : 'The Desktop is stopped, so VNC clients see nothing',
+ },
+ };
- async function updateHotspotButton() {
- const btn = document.getElementById('hotspot-btn');
+ const toggleStates = {};
+
+ async function updateToggleButton(name) {
+ const config = TOGGLE_BUTTONS[name];
+ const btn = document.getElementById(config.id);
if (!btn) return;
+ const startText = config.startText || `Start ${config.label}`;
+ const stopText = config.stopText || `Stop ${config.label}`;
try {
- const data = await apiCall('api/hotspot/status');
- hotspotEnabled = data.enabled;
+ const data = await apiCall(config.statusUrl);
+ toggleStates[name] = config.isOn(data);
+ btn.title = '';
if (!data.available) {
- btn.textContent = 'Hotspot Unavailable';
+ btn.textContent = `${config.label} Unavailable`;
btn.disabled = true;
btn.className = 'btn';
- } else if (data.enabled) {
- btn.textContent = 'Stop Hotspot';
+ btn.title = data.reason || '';
+ } else if (toggleStates[name]) {
+ btn.textContent = stopText;
btn.disabled = false;
btn.className = 'btn btn-warning';
+ if (config.onTitle) btn.title = config.onTitle(data);
+ } else if (config.canStart && !config.canStart(data)) {
+ // Installed and stopped, but something else must happen first.
+ btn.textContent = startText;
+ btn.disabled = true;
+ btn.className = 'btn';
+ btn.title = data.reason || '';
} else {
- btn.textContent = 'Start Hotspot';
+ btn.textContent = startText;
btn.disabled = false;
btn.className = 'btn btn-primary';
}
} catch (e) {
- btn.textContent = 'Hotspot Unavailable';
+ btn.textContent = `${config.label} Unavailable`;
btn.disabled = true;
}
}
- async function toggleHotspot() {
- const btn = document.getElementById('hotspot-btn');
+ async function refreshToggleButtons() {
+ await Promise.all(Object.keys(TOGGLE_BUTTONS).map(name => updateToggleButton(name)));
+ }
+
+ async function toggleSubsystem(name) {
+ const config = TOGGLE_BUTTONS[name];
+ const btn = document.getElementById(config.id);
+ const on = toggleStates[name];
+ if (on && config.confirmStop && !confirm(config.confirmStop)) {
+ return;
+ }
btn.disabled = true;
btn.innerHTML = '';
try {
- if (hotspotEnabled) {
- await apiCall('api/hotspot/stop', 'POST');
- } else {
- await apiCall('api/hotspot/start', 'POST');
- }
+ await apiCall(on ? config.stopUrl : config.startUrl, 'POST');
} catch (e) {
- alert(`Hotspot error: ${e.message}`);
+ alert(`${config.label} error: ${e.message}`);
+ }
+ await updateToggleButton(name);
+ for (const other of config.alsoRefresh || []) {
+ await updateToggleButton(other);
}
- await updateHotspotButton();
}
async function loadHotspotSettings() {
@@ -1496,160 +1549,13 @@
device: document.getElementById('hotspot-device').value,
protocol: document.getElementById('hotspot-protocol').value || null,
});
- await updateHotspotButton();
+ await updateToggleButton('hotspot');
} catch (e) {
alert(`Failed to save hotspot settings: ${e.message}`);
}
btn.disabled = false;
}
- // --- Bluetooth ---
- let bluetoothEnabled = null;
-
- async function updateBluetoothButton() {
- const btn = document.getElementById('bluetooth-btn');
- if (!btn) return;
- try {
- const data = await apiCall('api/bluetooth/status');
- bluetoothEnabled = data.enabled;
- if (!data.available) {
- btn.textContent = 'Bluetooth Unavailable';
- btn.disabled = true;
- btn.className = 'btn';
- } else if (data.enabled) {
- btn.textContent = 'Disable Bluetooth';
- btn.disabled = false;
- btn.className = 'btn btn-warning';
- } else {
- btn.textContent = 'Enable Bluetooth';
- btn.disabled = false;
- btn.className = 'btn btn-primary';
- }
- } catch (e) {
- btn.textContent = 'Bluetooth Unavailable';
- btn.disabled = true;
- }
- }
-
- async function toggleBluetooth() {
- const btn = document.getElementById('bluetooth-btn');
- btn.disabled = true;
- btn.innerHTML = '';
- try {
- if (bluetoothEnabled) {
- await apiCall('api/bluetooth/block', 'POST');
- } else {
- await apiCall('api/bluetooth/unblock', 'POST');
- }
- } catch (e) {
- alert(`Bluetooth error: ${e.message}`);
- }
- 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'
- + ' and the display will drop to a terminal. 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();
- await updateVncButton();
- }
-
- // --- VNC ---
- let vncRunning = null;
-
- async function updateVncButton() {
- const btn = document.getElementById('vnc-btn');
- if (!btn) return;
- try {
- const data = await apiCall('api/vnc/status');
- vncRunning = data.running;
- if (!data.available) {
- btn.textContent = 'VNC Unavailable';
- btn.disabled = true;
- btn.className = 'btn';
- btn.title = data.reason || '';
- } else if (data.running) {
- btn.textContent = 'Stop VNC';
- btn.disabled = false;
- btn.className = 'btn btn-warning';
- // VNC serves the desktop session, so say when there is nothing to serve.
- btn.title = data.desktop_running ? '' : 'The Desktop is stopped, so VNC clients see nothing';
- } else if (!data.can_start) {
- btn.textContent = 'Start VNC';
- btn.disabled = true;
- btn.className = 'btn';
- btn.title = data.reason || '';
- } else {
- btn.textContent = 'Start VNC';
- btn.disabled = false;
- btn.className = 'btn btn-primary';
- btn.title = '';
- }
- } catch (e) {
- btn.textContent = 'VNC Unavailable';
- btn.disabled = true;
- }
- }
-
- async function toggleVnc() {
- const btn = document.getElementById('vnc-btn');
- btn.disabled = true;
- btn.innerHTML = '';
- try {
- if (vncRunning) {
- await apiCall('api/vnc/stop', 'POST');
- } else {
- await apiCall('api/vnc/start', 'POST');
- }
- } catch (e) {
- alert(`VNC error: ${e.message}`);
- }
- await updateVncButton();
- }
-
async function waitForController(maxWaitMs = 60000) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
@@ -3030,10 +2936,7 @@
const updateInterval = 10000;
// Fetch hardware button status immediately
- updateHotspotButton();
- updateBluetoothButton();
- updateDesktopButton();
- updateVncButton();
+ refreshToggleButtons();
loadHotspotSettings();
// Initial update after a short delay (to not overlap with initial load)
@@ -3050,10 +2953,7 @@
updateServicesTable();
loadDisks();
loadSmart();
- updateHotspotButton();
- updateBluetoothButton();
- updateDesktopButton();
- updateVncButton();
+ refreshToggleButtons();
}, updateInterval);
}