diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index b740f701..9a69887e 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -223,6 +223,7 @@ impl RpcHandler { "network.list-interfaces" => self.handle_network_list_interfaces().await, "network.scan-wifi" => self.handle_network_scan_wifi().await, "network.configure-wifi" => self.handle_network_configure_wifi(params).await, + "network.set-wifi-radio" => self.handle_network_set_wifi_radio(params).await, "network.configure-ethernet" => self.handle_network_configure_ethernet(params).await, "network.dns-status" => self.handle_network_dns_status().await, "network.configure-dns" => self.handle_network_configure_dns(params).await, diff --git a/core/archipelago/src/api/rpc/interfaces.rs b/core/archipelago/src/api/rpc/interfaces.rs index 2e5b7fd3..bddb76a3 100644 --- a/core/archipelago/src/api/rpc/interfaces.rs +++ b/core/archipelago/src/api/rpc/interfaces.rs @@ -18,6 +18,24 @@ impl RpcHandler { Ok(serde_json::json!({ "networks": networks })) } + /// network.set-wifi-radio — turn the wifi adapter fully on or off (not just + /// disconnect from a network). Params: `{ "enabled": bool }`. + pub(super) async fn handle_network_set_wifi_radio( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let enabled = params + .get("enabled") + .and_then(|v| v.as_bool()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: enabled"))?; + + tracing::info!(enabled, "Setting wifi radio state"); + set_wifi_radio(enabled).await?; + + Ok(serde_json::json!({ "ok": true, "enabled": enabled })) + } + /// network.configure-wifi — connect to a WiFi network. pub(super) async fn handle_network_configure_wifi( &self, @@ -327,6 +345,27 @@ fn split_nmcli_escaped(line: &str, limit: usize) -> Vec { fields } +/// Turn the wifi radio fully on or off using nmcli (a rfkill-level toggle, not +/// just disconnecting from the current network — the adapter stops scanning/ +/// associating entirely until switched back on). +async fn set_wifi_radio(enabled: bool) -> Result<()> { + let state = if enabled { "on" } else { "off" }; + let output = tokio::process::Command::new("nmcli") + .args(["radio", "wifi", state]) + .output() + .await + .context("Failed to run nmcli radio wifi")?; + + if !output.status.success() { + anyhow::bail!( + "nmcli radio wifi {} failed: {}", + state, + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + /// Connect to a WiFi network using nmcli. async fn connect_wifi(ssid: &str, password: &str) -> Result<()> { let conn_name = format!("archipelago-wifi-{ssid}"); diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index b42b529f..2355d760 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -254,12 +254,28 @@

{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}

-
-

{{ iface.ipv4[0] }}

-

No IP

+
+
+

{{ iface.ipv4[0] }}

+

No IP

+
+

No physical interfaces detected

+

{{ wifiRadioError }}

@@ -568,6 +584,9 @@ const allInterfaces = ref([]) const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi')) const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi')) +const togglingWifiRadio = ref(false) +const wifiRadioError = ref('') + const showWifiModal = ref(false) const wifiScanning = ref(false) const wifiNetworks = ref([]) @@ -623,6 +642,20 @@ async function loadInterfaces() { try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false } } +async function toggleWifiRadio(iface: NetworkInterface) { + togglingWifiRadio.value = true + wifiRadioError.value = '' + const enabled = iface.state !== 'up' + try { + await rpcClient.call({ method: 'network.set-wifi-radio', params: { enabled } }) + await loadInterfaces() + } catch (e) { + wifiRadioError.value = e instanceof Error ? e.message : 'Failed to change wifi radio state.' + } finally { + togglingWifiRadio.value = false + } +} + function wifiRequiresPassword(network: WifiNetwork | undefined): boolean { const security = (network?.security || '').trim().toLowerCase() return security.length > 0 && security !== '--' && security !== 'none' && security !== 'open' diff --git a/neode-ui/src/views/server/OpenWrtGateway.vue b/neode-ui/src/views/server/OpenWrtGateway.vue index 3c6c5067..fd8c0aee 100644 --- a/neode-ui/src/views/server/OpenWrtGateway.vue +++ b/neode-ui/src/views/server/OpenWrtGateway.vue @@ -77,6 +77,10 @@ const showConnectForm = ref(false) const connecting = ref(false) const connectedParams = ref | null>(null) +const detecting = ref(false) +const detectError = ref('') +const detectedCandidates = ref([]) + const provisioning = ref(false) const provisionError = ref('') const provisionSuccess = ref(false) @@ -138,6 +142,61 @@ async function connect() { } } +interface WiredInterface { name: string; type: string; state: string; ipv4: string[] } + +async function detectRouter() { + detecting.value = true + detectError.value = '' + detectedCandidates.value = [] + try { + const { interfaces } = await rpcClient.call<{ interfaces: WiredInterface[] }>({ + method: 'network.list-interfaces', + timeout: 10000, + }) + const wired = interfaces.find(i => i.type === 'ethernet' && i.state === 'up' && i.ipv4.length > 0) + if (!wired) { + detectError.value = 'No active wired ethernet connection found on this node.' + return + } + const [ip, prefixStr] = wired.ipv4[0]!.split('/') + const prefix = Number(prefixStr) || 24 + + const { routers } = await rpcClient.call<{ routers: string[] }>({ + method: 'openwrt.scan', + params: { subnet: ip, prefix, ssh_user: sshUser.value, ssh_password: sshPassword.value }, + timeout: 120000, + }) + + if (routers.length === 0) { + detectError.value = `No OpenWrt router found on ${wired.name}'s network (/${prefix}).` + } else if (routers.length === 1) { + host.value = routers[0]! + } else { + detectedCandidates.value = routers + } + } catch (e) { + detectError.value = e instanceof Error ? e.message : String(e) + } finally { + detecting.value = false + } +} + +function pickDetectedRouter(ip: string) { + host.value = ip + detectedCandidates.value = [] +} + +// The router config now persists server-side (see handle_openwrt_get_status), +// so onMounted's no-args load() always reconnects automatically — without this, +// there'd be no way back to the connect form (and its Detect button) at all. +function disconnectRouter() { + host.value = status.value?.host ?? host.value + connectedParams.value = null + detectError.value = '' + detectedCandidates.value = [] + showConnectForm.value = true +} + async function provisionTollgate() { provisioning.value = true provisionError.value = '' @@ -314,12 +373,34 @@ onMounted(() => load())
- +
+ + +
+

{{ detectError }}

+
+

Multiple routers found — pick one:

+ +
@@ -403,9 +484,14 @@ onMounted(() => load())
{{ formatUptime(status.uptime_secs) }}
- +
+ + +