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

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

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

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

View file

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

View file

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

View file

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