wrolpi/controller/api/admin.py

339 lines
11 KiB
Python
Raw Normal View History

"""
Admin API endpoints for WROLPi Controller.
"""
from fastapi import APIRouter, HTTPException
from controller.api.schemas import (
BluetoothActionResponse,
BluetoothStatusResponse,
DesktopActionResponse,
DesktopStatusResponse,
HotspotActionResponse,
HotspotDevicesResponse,
HotspotProtocolsResponse,
HotspotSettingsRequest,
HotspotSettingsResponse,
HotspotStatusResponse,
RestartServicesResponse,
SambaShareAddRequest,
SambaShareRemoveResponse,
SambaShareResponse,
SambaStatusResponse,
ServiceRestartResult,
SystemActionResponse,
ThrottleActionResponse,
ThrottleStatusResponse,
TimezoneSetRequest,
TimezoneSetResponse,
TimezoneStatusResponse,
VncActionResponse,
VncStatusResponse,
)
Hotspot captive portal: welcome page with the WROLPi's address (#709) * Hotspot captive portal: welcome page with the WROLPi's address Devices that join the hotspot probe a fixed plain-HTTP URL to check for Internet access. NetworkManager's shared-mode dnsmasq now resolves those probe hostnames (and `wrolpi`) to the hotspot address via an include the Controller writes when the hotspot starts, the probes land on the Controller (port 80), and are redirected to /portal: "This is your WROLPi, its address is 10.42.0.1, open WROLPi / open the Controller". Only the probe hostnames are hijacked so the hotspot keeps forwarding everything else when the WROLPi has an uplink. The include is corrected and the connection re-activated once if NetworkManager picked a non-default subnet. Writing the include is best-effort; the hotspot starts regardless. `hotspot.captive_portal` (controller.yaml, default true) toggles the probe redirects and the include; like the other hotspot settings it applies on the next hotspot start, so saving while the hotspot is up reports restart_required and both Controller UIs say so. The status endpoint gains the hotspot address and portal URL, shown in both UIs. repair.sh only creates the include directory so a disabled setting survives upgrades. Native only: Docker has no hotspot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: address review * React: re-read the hotspot address whenever the HotspotToggle starts or stops the hotspot, matching the fallback UI's refresh-on-status. * A WPA3 start that reverts to WPA2 leaves the hotspot up, so it now gets the captive portal address correction too. * The corrective `nmcli connection up` logs a warning on a non-zero exit; dnsmasq would otherwise keep the old address silently. * HotspotStatusResponse.captive_portal defaults to true like everywhere else. * Trim design narrative from the module docstring and React comments. * The include-before-activate test now observes the include at the moment of the `nmcli ... hotspot` call instead of inferring from call order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: never forward any record type for probe hosts `address=` only synthesizes A/AAAA answers. iOS also asks for the HTTPS record type; dnsmasq forwarded that upstream, Apple's answer aliased to captive.g.aaplimg.com, and the phone fetched the genuine "Success" from Apple instead of the portal. Each probe host now also gets `local=`, so dnsmasq answers every type itself, and the CDN alias is a probe host too. Observed with dnsmasq query logging on a hotspot with an eth0 uplink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: let acknowledged clients finish so the OS offers "Done" A phone's sign-in sheet only shows "Done" once the OS re-probes and gets the genuine success reply. The Controller now remembers each client (by hotspot IP) that has loaded the portal page and answers its later probes with the reply each OS expects (Apple "Success" HTML, Android 204, Windows "Microsoft Connect Test", ...). The set is cleared when the hotspot starts so a new session shows the page again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: acknowledge on Continue, not on view iOS fetches the portal page in the background before opening the sheet, then re-probes after the sheet's own navigation. Acknowledging on view made that re-probe return "Success" as the sheet's content. Phones only re-check the network (and offer "Done") after a full-page navigation, so the welcome page now has a Continue button leading to /portal/continue, which is the only place a client is acknowledged. The Continue page says to tap Done and links to WROLPi and the Controller. Also hijack clients3.google.com (older Android probe host). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * help: bump wrolpi-help to the captive portal and Speed Test docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: comments describe the Continue design; clear acks on stop Three comments still described acknowledging on view, which c72a7f7b removed. Acknowledged clients are now also cleared when the hotspot stops, so a lease reused in a later session sees the portal page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 20:09:48 -06:00
from controller.lib.captive_portal import is_captive_portal_enabled
from controller.lib.admin import (
block_bluetooth,
disable_throttle,
enable_throttle,
get_bluetooth_status_dict,
get_desktop_status_dict,
get_vnc_status_dict,
get_device_hotspot_protocols,
get_hotspot_device,
get_hotspot_password,
get_hotspot_protocol,
get_hotspot_ssid,
get_hotspot_status_dict,
get_throttle_status_dict,
get_timezone_status_dict,
get_wifi_devices,
reboot_system,
restart_all_services,
set_timezone,
shutdown_system,
start_desktop,
start_hotspot,
start_vnc,
stop_desktop,
stop_hotspot,
stop_vnc,
unblock_bluetooth,
update_hotspot_settings,
)
from controller.lib.config import is_docker_mode
from controller.lib.samba import (
add_share,
get_samba_status_dict,
remove_share,
)
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)
async def hotspot_status() -> HotspotStatusResponse:
"""Get WiFi hotspot status."""
return HotspotStatusResponse(**get_hotspot_status_dict())
@router.get("/api/hotspot/devices", response_model=HotspotDevicesResponse)
async def hotspot_devices() -> HotspotDevicesResponse:
"""List WiFi devices which could host the hotspot."""
return HotspotDevicesResponse(devices=get_wifi_devices())
@router.get("/api/hotspot/protocols", response_model=HotspotProtocolsResponse)
async def hotspot_protocols(device: str = None) -> HotspotProtocolsResponse:
"""List the hotspot protocols a WiFi device can host; defaults to the configured device."""
device = device or get_hotspot_device()
return HotspotProtocolsResponse(device=device, protocols=get_device_hotspot_protocols(device))
@router.get("/api/hotspot/settings", response_model=HotspotSettingsResponse)
async def hotspot_settings() -> HotspotSettingsResponse:
"""Get WiFi hotspot settings."""
return HotspotSettingsResponse(
device=get_hotspot_device(),
ssid=get_hotspot_ssid(),
password=get_hotspot_password(),
protocol=get_hotspot_protocol(),
Hotspot captive portal: welcome page with the WROLPi's address (#709) * Hotspot captive portal: welcome page with the WROLPi's address Devices that join the hotspot probe a fixed plain-HTTP URL to check for Internet access. NetworkManager's shared-mode dnsmasq now resolves those probe hostnames (and `wrolpi`) to the hotspot address via an include the Controller writes when the hotspot starts, the probes land on the Controller (port 80), and are redirected to /portal: "This is your WROLPi, its address is 10.42.0.1, open WROLPi / open the Controller". Only the probe hostnames are hijacked so the hotspot keeps forwarding everything else when the WROLPi has an uplink. The include is corrected and the connection re-activated once if NetworkManager picked a non-default subnet. Writing the include is best-effort; the hotspot starts regardless. `hotspot.captive_portal` (controller.yaml, default true) toggles the probe redirects and the include; like the other hotspot settings it applies on the next hotspot start, so saving while the hotspot is up reports restart_required and both Controller UIs say so. The status endpoint gains the hotspot address and portal URL, shown in both UIs. repair.sh only creates the include directory so a disabled setting survives upgrades. Native only: Docker has no hotspot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: address review * React: re-read the hotspot address whenever the HotspotToggle starts or stops the hotspot, matching the fallback UI's refresh-on-status. * A WPA3 start that reverts to WPA2 leaves the hotspot up, so it now gets the captive portal address correction too. * The corrective `nmcli connection up` logs a warning on a non-zero exit; dnsmasq would otherwise keep the old address silently. * HotspotStatusResponse.captive_portal defaults to true like everywhere else. * Trim design narrative from the module docstring and React comments. * The include-before-activate test now observes the include at the moment of the `nmcli ... hotspot` call instead of inferring from call order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: never forward any record type for probe hosts `address=` only synthesizes A/AAAA answers. iOS also asks for the HTTPS record type; dnsmasq forwarded that upstream, Apple's answer aliased to captive.g.aaplimg.com, and the phone fetched the genuine "Success" from Apple instead of the portal. Each probe host now also gets `local=`, so dnsmasq answers every type itself, and the CDN alias is a probe host too. Observed with dnsmasq query logging on a hotspot with an eth0 uplink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: let acknowledged clients finish so the OS offers "Done" A phone's sign-in sheet only shows "Done" once the OS re-probes and gets the genuine success reply. The Controller now remembers each client (by hotspot IP) that has loaded the portal page and answers its later probes with the reply each OS expects (Apple "Success" HTML, Android 204, Windows "Microsoft Connect Test", ...). The set is cleared when the hotspot starts so a new session shows the page again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: acknowledge on Continue, not on view iOS fetches the portal page in the background before opening the sheet, then re-probes after the sheet's own navigation. Acknowledging on view made that re-probe return "Success" as the sheet's content. Phones only re-check the network (and offer "Done") after a full-page navigation, so the welcome page now has a Continue button leading to /portal/continue, which is the only place a client is acknowledged. The Continue page says to tap Done and links to WROLPi and the Controller. Also hijack clients3.google.com (older Android probe host). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * help: bump wrolpi-help to the captive portal and Speed Test docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: comments describe the Continue design; clear acks on stop Three comments still described acknowledging on view, which c72a7f7b removed. Acknowledged clients are now also cleared when the hotspot stops, so a lease reused in a later session sees the portal page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 20:09:48 -06:00
captive_portal=is_captive_portal_enabled(),
)
@router.post("/api/hotspot/settings", response_model=HotspotSettingsResponse)
async def hotspot_settings_update(request: HotspotSettingsRequest) -> HotspotSettingsResponse:
"""Update WiFi hotspot settings; they are saved in controller.yaml."""
# 500 matches the other subsystem endpoints (hotspot/bluetooth/throttle) in Docker mode.
if is_docker_mode():
raise HTTPException(status_code=500, detail="Not available in Docker mode")
result = update_hotspot_settings(device=request.device, ssid=request.ssid, password=request.password,
Hotspot captive portal: welcome page with the WROLPi's address (#709) * Hotspot captive portal: welcome page with the WROLPi's address Devices that join the hotspot probe a fixed plain-HTTP URL to check for Internet access. NetworkManager's shared-mode dnsmasq now resolves those probe hostnames (and `wrolpi`) to the hotspot address via an include the Controller writes when the hotspot starts, the probes land on the Controller (port 80), and are redirected to /portal: "This is your WROLPi, its address is 10.42.0.1, open WROLPi / open the Controller". Only the probe hostnames are hijacked so the hotspot keeps forwarding everything else when the WROLPi has an uplink. The include is corrected and the connection re-activated once if NetworkManager picked a non-default subnet. Writing the include is best-effort; the hotspot starts regardless. `hotspot.captive_portal` (controller.yaml, default true) toggles the probe redirects and the include; like the other hotspot settings it applies on the next hotspot start, so saving while the hotspot is up reports restart_required and both Controller UIs say so. The status endpoint gains the hotspot address and portal URL, shown in both UIs. repair.sh only creates the include directory so a disabled setting survives upgrades. Native only: Docker has no hotspot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: address review * React: re-read the hotspot address whenever the HotspotToggle starts or stops the hotspot, matching the fallback UI's refresh-on-status. * A WPA3 start that reverts to WPA2 leaves the hotspot up, so it now gets the captive portal address correction too. * The corrective `nmcli connection up` logs a warning on a non-zero exit; dnsmasq would otherwise keep the old address silently. * HotspotStatusResponse.captive_portal defaults to true like everywhere else. * Trim design narrative from the module docstring and React comments. * The include-before-activate test now observes the include at the moment of the `nmcli ... hotspot` call instead of inferring from call order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: never forward any record type for probe hosts `address=` only synthesizes A/AAAA answers. iOS also asks for the HTTPS record type; dnsmasq forwarded that upstream, Apple's answer aliased to captive.g.aaplimg.com, and the phone fetched the genuine "Success" from Apple instead of the portal. Each probe host now also gets `local=`, so dnsmasq answers every type itself, and the CDN alias is a probe host too. Observed with dnsmasq query logging on a hotspot with an eth0 uplink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: let acknowledged clients finish so the OS offers "Done" A phone's sign-in sheet only shows "Done" once the OS re-probes and gets the genuine success reply. The Controller now remembers each client (by hotspot IP) that has loaded the portal page and answers its later probes with the reply each OS expects (Apple "Success" HTML, Android 204, Windows "Microsoft Connect Test", ...). The set is cleared when the hotspot starts so a new session shows the page again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: acknowledge on Continue, not on view iOS fetches the portal page in the background before opening the sheet, then re-probes after the sheet's own navigation. Acknowledging on view made that re-probe return "Success" as the sheet's content. Phones only re-check the network (and offer "Done") after a full-page navigation, so the welcome page now has a Continue button leading to /portal/continue, which is the only place a client is acknowledged. The Continue page says to tap Done and links to WROLPi and the Controller. Also hijack clients3.google.com (older Android probe host). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * help: bump wrolpi-help to the captive portal and Speed Test docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: comments describe the Continue design; clear acks on stop Three comments still described acknowledging on view, which c72a7f7b removed. Acknowledged clients are now also cleared when the hotspot stops, so a lease reused in a later session sees the portal page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 20:09:48 -06:00
protocol=request.protocol, captive_portal=request.captive_portal)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
return HotspotSettingsResponse(device=result["device"], ssid=result["ssid"], password=result["password"],
Hotspot captive portal: welcome page with the WROLPi's address (#709) * Hotspot captive portal: welcome page with the WROLPi's address Devices that join the hotspot probe a fixed plain-HTTP URL to check for Internet access. NetworkManager's shared-mode dnsmasq now resolves those probe hostnames (and `wrolpi`) to the hotspot address via an include the Controller writes when the hotspot starts, the probes land on the Controller (port 80), and are redirected to /portal: "This is your WROLPi, its address is 10.42.0.1, open WROLPi / open the Controller". Only the probe hostnames are hijacked so the hotspot keeps forwarding everything else when the WROLPi has an uplink. The include is corrected and the connection re-activated once if NetworkManager picked a non-default subnet. Writing the include is best-effort; the hotspot starts regardless. `hotspot.captive_portal` (controller.yaml, default true) toggles the probe redirects and the include; like the other hotspot settings it applies on the next hotspot start, so saving while the hotspot is up reports restart_required and both Controller UIs say so. The status endpoint gains the hotspot address and portal URL, shown in both UIs. repair.sh only creates the include directory so a disabled setting survives upgrades. Native only: Docker has no hotspot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: address review * React: re-read the hotspot address whenever the HotspotToggle starts or stops the hotspot, matching the fallback UI's refresh-on-status. * A WPA3 start that reverts to WPA2 leaves the hotspot up, so it now gets the captive portal address correction too. * The corrective `nmcli connection up` logs a warning on a non-zero exit; dnsmasq would otherwise keep the old address silently. * HotspotStatusResponse.captive_portal defaults to true like everywhere else. * Trim design narrative from the module docstring and React comments. * The include-before-activate test now observes the include at the moment of the `nmcli ... hotspot` call instead of inferring from call order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: never forward any record type for probe hosts `address=` only synthesizes A/AAAA answers. iOS also asks for the HTTPS record type; dnsmasq forwarded that upstream, Apple's answer aliased to captive.g.aaplimg.com, and the phone fetched the genuine "Success" from Apple instead of the portal. Each probe host now also gets `local=`, so dnsmasq answers every type itself, and the CDN alias is a probe host too. Observed with dnsmasq query logging on a hotspot with an eth0 uplink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: let acknowledged clients finish so the OS offers "Done" A phone's sign-in sheet only shows "Done" once the OS re-probes and gets the genuine success reply. The Controller now remembers each client (by hotspot IP) that has loaded the portal page and answers its later probes with the reply each OS expects (Apple "Success" HTML, Android 204, Windows "Microsoft Connect Test", ...). The set is cleared when the hotspot starts so a new session shows the page again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: acknowledge on Continue, not on view iOS fetches the portal page in the background before opening the sheet, then re-probes after the sheet's own navigation. Acknowledging on view made that re-probe return "Success" as the sheet's content. Phones only re-check the network (and offer "Done") after a full-page navigation, so the welcome page now has a Continue button leading to /portal/continue, which is the only place a client is acknowledged. The Continue page says to tap Done and links to WROLPi and the Controller. Also hijack clients3.google.com (older Android probe host). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * help: bump wrolpi-help to the captive portal and Speed Test docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * captive portal: comments describe the Continue design; clear acks on stop Three comments still described acknowledging on view, which c72a7f7b removed. Acknowledged clients are now also cleared when the hotspot stops, so a lease reused in a later session sees the portal page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 20:09:48 -06:00
protocol=result["protocol"], captive_portal=result["captive_portal"],
restart_required=result.get("restart_required", False))
@router.post("/api/hotspot/start", response_model=HotspotActionResponse)
async def hotspot_start() -> HotspotActionResponse:
"""Start the WiFi hotspot."""
result = start_hotspot()
_raise_for_action(result)
return HotspotActionResponse(**result)
@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()
_raise_for_action(result)
return HotspotActionResponse(**result)
# --- Bluetooth ---
@router.get("/api/bluetooth/status", response_model=BluetoothStatusResponse)
async def bluetooth_status() -> BluetoothStatusResponse:
"""Get Bluetooth radio status."""
return BluetoothStatusResponse(**await get_bluetooth_status_dict())
@router.post("/api/bluetooth/unblock", response_model=BluetoothActionResponse)
async def bluetooth_unblock() -> BluetoothActionResponse:
"""Unblock the Bluetooth radio (turn it on)."""
result = await unblock_bluetooth()
_raise_for_action(result)
return BluetoothActionResponse(**result)
@router.post("/api/bluetooth/block", response_model=BluetoothActionResponse)
async def bluetooth_block() -> BluetoothActionResponse:
"""Block the Bluetooth radio (turn it off)."""
result = await block_bluetooth()
_raise_for_action(result)
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()
_raise_for_action(result)
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()
_raise_for_action(result)
return DesktopActionResponse(**result)
# --- VNC ---
@router.get("/api/vnc/status", response_model=VncStatusResponse)
async def vnc_status() -> VncStatusResponse:
"""Get VNC server status."""
return VncStatusResponse(**await get_vnc_status_dict())
@router.post("/api/vnc/start", response_model=VncActionResponse)
async def vnc_start() -> VncActionResponse:
"""Start the VNC server; requires a running desktop."""
result = await start_vnc()
_raise_for_action(result)
return VncActionResponse(success=True)
@router.post("/api/vnc/stop", response_model=VncActionResponse)
async def vnc_stop() -> VncActionResponse:
"""Stop the VNC server; allowed even when the desktop is stopped."""
result = await stop_vnc()
_raise_for_action(result)
return VncActionResponse(success=True)
# --- Samba ---
@router.get("/api/samba/status", response_model=SambaStatusResponse)
async def samba_status() -> SambaStatusResponse:
"""Get Samba sharing status."""
return SambaStatusResponse(**get_samba_status_dict())
@router.post("/api/samba/shares", response_model=SambaShareResponse)
async def samba_add_share(request: SambaShareAddRequest) -> SambaShareResponse:
"""Add a Samba share."""
result = add_share(request.name, request.path, request.read_only, request.comment or "")
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
return SambaShareResponse(**result)
@router.delete("/api/samba/shares/{share_name}", response_model=SambaShareRemoveResponse)
async def samba_remove_share(share_name: str) -> SambaShareRemoveResponse:
"""Remove a Samba share."""
result = remove_share(share_name)
if not result.get("success"):
raise HTTPException(status_code=404, detail=result.get("error", "Failed"))
return SambaShareRemoveResponse(**result)
# --- Throttle ---
@router.get("/api/throttle/status", response_model=ThrottleStatusResponse)
async def throttle_status() -> ThrottleStatusResponse:
"""Get CPU throttle status."""
return ThrottleStatusResponse(**get_throttle_status_dict())
@router.post("/api/throttle/enable", response_model=ThrottleActionResponse)
async def throttle_enable() -> ThrottleActionResponse:
"""Enable CPU throttle (powersave mode)."""
result = enable_throttle()
_raise_for_action(result)
return ThrottleActionResponse(**result)
@router.post("/api/throttle/disable", response_model=ThrottleActionResponse)
async def throttle_disable() -> ThrottleActionResponse:
"""Disable CPU throttle (normal performance)."""
result = disable_throttle()
_raise_for_action(result)
return ThrottleActionResponse(**result)
# --- Timezone ---
@router.get("/api/timezone/status", response_model=TimezoneStatusResponse)
async def timezone_status() -> TimezoneStatusResponse:
"""Get current system timezone."""
return TimezoneStatusResponse(**get_timezone_status_dict())
@router.post("/api/timezone/set", response_model=TimezoneSetResponse)
async def timezone_set(request: TimezoneSetRequest) -> TimezoneSetResponse:
"""Set the system timezone."""
result = set_timezone(request.timezone)
_raise_for_action(result)
return TimezoneSetResponse(**result)
# --- System Control ---
@router.post("/api/shutdown", response_model=SystemActionResponse)
async def system_shutdown() -> SystemActionResponse:
"""Shut down the system."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Cannot shutdown from Docker container"
)
result = shutdown_system()
_raise_for_action(result)
return SystemActionResponse(**result)
@router.post("/api/reboot", response_model=SystemActionResponse)
async def system_reboot() -> SystemActionResponse:
"""Reboot the system."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Cannot reboot from Docker container"
)
result = reboot_system()
_raise_for_action(result)
return SystemActionResponse(**result)
@router.post("/api/restart", response_model=RestartServicesResponse)
async def services_restart() -> RestartServicesResponse:
"""Restart all WROLPi services including Controller."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Service restart via systemctl not available in Docker mode"
)
result = await restart_all_services()
# Convert nested dicts to Pydantic models
services = {
name: ServiceRestartResult(**status)
for name, status in result.get("services", {}).items()
}
return RestartServicesResponse(
success=result.get("success", False),
services=services,
error=result.get("error"),
)