Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion native/src/ctt-lcd/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0
0.4.1
49 changes: 47 additions & 2 deletions src/hardware/pi/network/wifi.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ import { promisify } from 'util'

const pExecFile = promisify(execFile)

/**
* Name of the currently-connected wifi device (usually wlan0, but don't
* hardcode it), or null when no wifi device is connected. Used by the IP
* lookup. Async execFile only — same event-loop-safety rationale as
* GetNetworkList.
* @returns {Promise<String|null>}
*/
const GetConnectedWifiDevice = async () => {
const { stdout } = await pExecFile(
'nmcli',
['-t', '-f', 'DEVICE,TYPE,STATE', 'device'],
{ encoding: 'utf8', timeout: 8000 }
)
const row = stdout.trim().split('\n')
.map(line => line.split(':'))
.find(([, type, state]) => type === 'wifi' && state === 'connected')
return row ? row[0] : null
}

/**
* `nmcli device wifi list` can trigger a blocking rescan (seconds). Run it via
* async execFile — never execSync — so a slow/hung scan can't freeze the event
Expand All @@ -28,14 +47,40 @@ const GetNetworkList = async () => {
})
}

/**
* IPv4 address of whichever wifi device is currently connected, or null.
* Async execFile only — same event-loop-safety rationale as GetNetworkList.
* @returns {Promise<String|null>}
*/
const GetCurrentIp = async () => {
// Find the connected wifi device (usually wlan0, but don't hardcode it).
const device = await GetConnectedWifiDevice()
if (!device) return null
const { stdout } = await pExecFile(
'nmcli',
['-t', '-f', 'IP4.ADDRESS', 'device', 'show', device],
{ encoding: 'utf8', timeout: 8000 }
)
// Lines look like: IP4.ADDRESS[1]:192.168.1.5/24
const line = stdout.trim().split('\n').find(Boolean)
if (!line) return null
const value = line.split(':').slice(1).join(':').trim() // 192.168.1.5/24
return value ? value.split('/')[0] : null
}

export default Object.freeze({
/**
*
* @returns {Promise<Object>} return
* The currently-connected wifi network (`signal` is the nmcli 0-100 percent),
* or undefined when nothing is connected.
* @returns {Promise<Object|undefined>} connected network, or undefined
*/
GetCurrentNetwork: async () => {
return (await GetNetworkList()).find(network => network.connected)
},
/**
* @returns {Promise<String|null>} IPv4 address of the connected wifi device
*/
GetCurrentIp,
/**
*
* @returns {Promise<Array>} array of networks visible to twifi
Expand Down
19 changes: 18 additions & 1 deletion src/station-hardware-server/routes/internet.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,33 @@ router.get('/pending-upload', async (req, res, next) => {
router.get('/wifi-networks', async (req, res, next) => {
const wifi = await Wifi.GetCurrentNetwork()
if (wifi) {
res.json(wifi)
let ip = null
try {
ip = await Wifi.GetCurrentIp()
} catch (err) {
console.error('error getting wifi ip', err)
}
res.json({ ...wifi, ip })
} else {
res.json({
signal: undefined,
state: false,
ip: null,
})
}

})

// Full list of visible WiFi networks, for the dashboard's "scan" dropdown.
router.get('/wifi-scan', async (req, res) => {
try {
res.json(await Wifi.GetNetworks())
} catch (err) {
console.error('wifi scan failed', err)
res.status(500).json({ error: 'scan failed' })
}
})

router.get('/delete-connections', async (req, res, next) => {
const results = await RunCommand('/bin/bash system/scripts/delete-credentials.sh')
// return res.status(200).send()
Expand Down
2 changes: 2 additions & 0 deletions src/station-interface/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ app.post('/modem/disable', Routes.Controls.ModemDisable)
app.get('/modem-signal-strength', Routes.Controls.ModemSignalStrength)
app.post('/wifi/enable', Routes.Controls.WifiEnable)
app.post('/wifi/disable', Routes.Controls.WifiDisable)
app.post('/wifi/connect', Routes.Controls.WifiConnect)
app.get('/wifi/networks', Routes.Controls.WifiNetworks)

app.use((req, res) => {
res.sendStatus(404)
Expand Down
128 changes: 128 additions & 0 deletions src/station-interface/public/javascripts/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,96 @@ const initialize_controls = function () {
}
})

// The dropdown IS the scan control: opening it kicks off a WiFi scan and
// repopulates itself with the results. No separate "Scan" button.
const wifiSsidList = document.querySelector('#wifi-ssid-list')
let wifiScanning = false
let wifiLastScan = 0
const WIFI_SCAN_TTL = 15000 // keep results this long before re-scanning on open
const setSsidPlaceholder = (text) => {
wifiSsidList.innerHTML = `<option value="">${text}</option>`
}
const scanWifiNetworks = async () => {
if (wifiScanning) return
// Don't wipe a fresh list on every open — that would clobber the option
// the user is about to click. Only rescan when the list is empty or stale.
const fresh = (Date.now() - wifiLastScan) < WIFI_SCAN_TTL && wifiSsidList.options.length > 1
if (fresh) return
wifiScanning = true
setSsidPlaceholder('Scanning…')
try {
const response = await fetch('/wifi/networks')
if (!response.ok) throw new Error('scan failed')
const nets = await response.json()
const seen = new Set()
const ssids = (Array.isArray(nets) ? nets : [])
.filter(n => n && n.ssid && !seen.has(n.ssid) && seen.add(n.ssid))
.sort((a, b) => (b.signal || 0) - (a.signal || 0))
setSsidPlaceholder('— pick a scanned network, or type below —')
for (const n of ssids) {
const opt = document.createElement('option')
opt.value = n.ssid
opt.textContent = `${n.ssid} (${Number.isFinite(n.signal) ? n.signal + '%' : '?'})`
wifiSsidList.appendChild(opt)
}
if (!ssids.length) setSsidPlaceholder('No networks found — reopen to rescan (enable WiFi?)')
else wifiLastScan = Date.now()
} catch (err) {
setSsidPlaceholder('Scan failed — reopen to retry (enable WiFi?)')
} finally {
wifiScanning = false
}
}
// mousedown fires as the dropdown opens (before options render); focus covers
// keyboard access. The first open scans; re-opening within the TTL keeps the
// list intact so a click can select a network (fills the SSID input below).
wifiSsidList.addEventListener('mousedown', () => { scanWifiNetworks() })
wifiSsidList.addEventListener('focus', () => { scanWifiNetworks() })
wifiSsidList.addEventListener('change', (e) => {
if (e.target.value) document.querySelector('#wifi-ssid').value = e.target.value
})

// Show/hide the WiFi password by toggling the input type.
const wifiPskShow = document.querySelector('#wifi-psk-show')
if (wifiPskShow) {
wifiPskShow.addEventListener('change', (e) => {
document.querySelector('#wifi-psk').type = e.target.checked ? 'text' : 'password'
})
}

document.querySelector('#wifi-connect').addEventListener('click', async (e) => {
const ssid = document.querySelector('#wifi-ssid').value.trim()
const psk = document.querySelector('#wifi-psk').value
if (!ssid) {
alert('Enter a WiFi SSID (network name)')
return
}
if (!window.confirm(`Connect to "${ssid}"? If you are reaching the Web Interface over the station's current network, connecting may change how it is reachable.`)) {
return
}
const btn = e.currentTarget
btn.disabled = true
try {
const response = await fetch('/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, psk }),
})
if (response.ok) {
alert(`Connected to "${ssid}"`)
document.querySelector('#wifi-psk').value = ''
} else {
let msg = 'Something went wrong connecting to WiFi (is WiFi enabled and the password correct?)'
try { const j = await response.json(); if (j && j.detail) msg += `\n\n${j.detail}` } catch (_) {}
alert(msg)
}
} catch (err) {
alert('Something went wrong connecting to WiFi')
} finally {
btn.disabled = false
}
})

document.querySelector('#max-row-count').value = MAX_ROW_COUNT
document.querySelector('#update-max-row-count').addEventListener('click', function (e) {
MAX_ROW_COUNT = document.querySelector('#max-row-count').value
Expand Down Expand Up @@ -1161,7 +1251,12 @@ const render_modem = function () {
.then(function (res) { return res.json() })
.then(function (json) {

const modemText = document.querySelector('#modem-signal-text')
const carrierEl = document.querySelector('#modem-carrier')

if (json == null) {
if (modemText) modemText.textContent = '—'
if (carrierEl) carrierEl.textContent = '—'
document.querySelector('#modem-icon').setAttribute('class', 'bi bi-reception-0')
document.querySelector('#modem-icon').setAttribute('style', "width:50; height:30; fill:red;")
document.querySelector('.modem-path0').setAttribute('d', "M0 13.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5m4 0a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5m4 0a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5m4 0a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5")
Expand All @@ -1170,6 +1265,18 @@ const render_modem = function () {
let signal = json.signal
let state = json.state

// dBm read-out: use the percent-derived RSSI the modem cache always
// provides, matching what the LCD shows (station-stats.js) so the two
// surfaces agree.
const rssiNum = (json.rssi != null) ? parseFloat(json.rssi) : NaN
const dbm = Number.isFinite(rssiNum) ? Math.round(rssiNum) : null
if (modemText) {
modemText.textContent = Number.isFinite(signal)
? `${signal}%${dbm != null ? ` / ${dbm} dBm` : ''}`
: '—'
}
if (carrierEl) carrierEl.textContent = json.carrier || '—'

if (state == "connected") {

if (signal > 75) {
Expand Down Expand Up @@ -1217,6 +1324,27 @@ const render_wifi = function () {
.then(function (json) {
let percent = json.signal
let state = json.connected
const ipEl = document.querySelector('#wifi-ip-address')
if (ipEl) {
ipEl.textContent = (state == true && json.ip) ? json.ip : '—'
}

// Numeric read-out next to the icon: signal percent from nmcli.
const wifiText = document.querySelector('#wifi-signal-text')
if (wifiText) {
wifiText.textContent = (state == true && Number.isFinite(percent))
? `${percent}%`
: '—'
}
// Prefill the SSID input with the currently-connected network. Only do it
// once, and never while the user is editing or has already typed/picked a
// value, so we don't clobber a connect-to-a-different-network attempt.
const ssidInput = document.querySelector('#wifi-ssid')
if (ssidInput && !window.__wifiSsidPrefilled && state == true && json.ssid
&& document.activeElement !== ssidInput && ssidInput.value === '') {
ssidInput.value = json.ssid
window.__wifiSsidPrefilled = true
}
if (state == true) {


Expand Down
6 changes: 5 additions & 1 deletion src/station-interface/routes/controls/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import Software from './software.js'
import UpdateRebootSchedule from './update-reboot-schedule.js'
import RebootSchedule from './reboot-schedule.js'
import Update from './update.js'
import WifiConnect from './wifi-connect.js'
import WifiDisable from './wifi-disable.js'
import WifiEnable from './wifi-enable.js'
import WifiNetworks from './wifi-networks.js'

export default {
Chrony,
Expand All @@ -30,6 +32,8 @@ export default {
RebootSchedule,
UpdateRebootSchedule,
Update,
WifiConnect,
WifiDisable,
WifiEnable
WifiEnable,
WifiNetworks
}
42 changes: 42 additions & 0 deletions src/station-interface/routes/controls/wifi-connect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// POST /wifi/connect — join a WiFi network with an SSID + password entered in the
// dashboard (behind auth). Mirrors what /usb/wifi does from a USB credentials
// file, but takes the values from the request body.
//
// SECURITY: use execFile with an argv array, NOT the shell-based RunCommand
// (src/command.js uses exec()). The SSID/password are untrusted operator input,
// so they must never be interpolated into a shell string — execFile passes them
// as literal argv to nmcli, so a name/password containing spaces or shell
// metacharacters can't inject a command.
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'

const pExecFile = promisify(execFile)

export default async (req, res) => {
const ssid = (req.body?.ssid ?? '').toString().trim()
const psk = (req.body?.psk ?? '').toString() // empty allowed (open network)

if (!ssid) {
return res.status(400).json({ error: 'ssid required' })
}

try {
// `nmcli dev wifi connect <ssid> [password <psk>]` creates the connection
// profile (named after the SSID) and activates it. Requires WiFi to be
// enabled first (the enable-wifi driver load); if it isn't, nmcli reports no
// wifi device and we surface that error rather than hanging.
const args = ['dev', 'wifi', 'connect', ssid]
if (psk) args.push('password', psk)
await pExecFile('nmcli', args, { timeout: 45000 })

// New wifi profiles default to DHCP, but set it explicitly to be safe.
await pExecFile('nmcli', ['connection', 'modify', ssid, 'ipv4.method', 'auto'], { timeout: 10000 })

return res.status(200).json({ ok: true, ssid })
} catch (err) {
const detail = (err?.stderr || err?.message || '').toString().trim()
console.log('wifi connect failed for SSID', JSON.stringify(ssid), '-', detail)
// Don't echo the password back; only the nmcli error text.
return res.status(500).json({ error: 'connect failed', detail: detail.slice(0, 300) })
}
}
13 changes: 13 additions & 0 deletions src/station-interface/routes/controls/wifi-networks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// GET /wifi/networks — proxy the hardware-server WiFi scan for the dashboard's
// "Scan for networks" dropdown. Returns the array of visible networks
// ({ ssid, signal, ... }); the client dedupes + sorts by signal.
export default async (req, res) => {
try {
const response = await fetch('http://localhost:3000/internet/wifi-scan')
if (!response.ok) throw new Error(`hardware-server ${response.status}`)
res.json(await response.json())
} catch (err) {
console.error('wifi scan proxy failed', err)
res.sendStatus(500)
}
}
Loading
Loading