diff --git a/native/src/ctt-lcd/VERSION b/native/src/ctt-lcd/VERSION index 1d0ba9ea..267577d4 100644 --- a/native/src/ctt-lcd/VERSION +++ b/native/src/ctt-lcd/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1 diff --git a/src/hardware/pi/network/wifi.js b/src/hardware/pi/network/wifi.js index a8597e1a..910a8484 100644 --- a/src/hardware/pi/network/wifi.js +++ b/src/hardware/pi/network/wifi.js @@ -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} + */ +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 @@ -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} + */ +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} return + * The currently-connected wifi network (`signal` is the nmcli 0-100 percent), + * or undefined when nothing is connected. + * @returns {Promise} connected network, or undefined */ GetCurrentNetwork: async () => { return (await GetNetworkList()).find(network => network.connected) }, + /** + * @returns {Promise} IPv4 address of the connected wifi device + */ + GetCurrentIp, /** * * @returns {Promise} array of networks visible to twifi diff --git a/src/station-hardware-server/routes/internet.js b/src/station-hardware-server/routes/internet.js index 22efacf0..d1f5d292 100644 --- a/src/station-hardware-server/routes/internet.js +++ b/src/station-hardware-server/routes/internet.js @@ -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() diff --git a/src/station-interface/app.js b/src/station-interface/app.js index 1855491f..b1714f75 100644 --- a/src/station-interface/app.js +++ b/src/station-interface/app.js @@ -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) diff --git a/src/station-interface/public/javascripts/interface.js b/src/station-interface/public/javascripts/interface.js index 20e6c37d..98475edd 100644 --- a/src/station-interface/public/javascripts/interface.js +++ b/src/station-interface/public/javascripts/interface.js @@ -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 = `` + } + 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 @@ -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") @@ -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) { @@ -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) { diff --git a/src/station-interface/routes/controls/index.js b/src/station-interface/routes/controls/index.js index a3decc8d..d514ca20 100644 --- a/src/station-interface/routes/controls/index.js +++ b/src/station-interface/routes/controls/index.js @@ -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, @@ -30,6 +32,8 @@ export default { RebootSchedule, UpdateRebootSchedule, Update, + WifiConnect, WifiDisable, - WifiEnable + WifiEnable, + WifiNetworks } \ No newline at end of file diff --git a/src/station-interface/routes/controls/wifi-connect.js b/src/station-interface/routes/controls/wifi-connect.js new file mode 100644 index 00000000..c56811df --- /dev/null +++ b/src/station-interface/routes/controls/wifi-connect.js @@ -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 [password ]` 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) }) + } +} diff --git a/src/station-interface/routes/controls/wifi-networks.js b/src/station-interface/routes/controls/wifi-networks.js new file mode 100644 index 00000000..84468431 --- /dev/null +++ b/src/station-interface/routes/controls/wifi-networks.js @@ -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) + } +} diff --git a/src/station-interface/views/main.pug b/src/station-interface/views/main.pug index d5a9e9c8..b2db6ba0 100644 --- a/src/station-interface/views/main.pug +++ b/src/station-interface/views/main.pug @@ -95,18 +95,26 @@ html(xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en") tr th Wifi Signal Strength td - div(id="wifi-signal-strength" style="display:inline-flex; align-items:flex-end; justify-content:flex-end; width:auto; height:35px; padding:4px") - svg(id='wifi-icon' xmlns="http://www.w3.org/2000/svg" style="width:50; height:30; fill:none;") - g(class="wifi-scale" transform="scale(2)") - path(class='wifi-path0') - path(class='wifi-path1') + div(style="display:inline-flex; align-items:center; gap:10px") + div(id="wifi-signal-strength" style="display:inline-flex; align-items:flex-end; justify-content:flex-end; width:auto; height:35px; padding:4px") + svg(id='wifi-icon' xmlns="http://www.w3.org/2000/svg" style="width:50; height:30; fill:none;") + g(class="wifi-scale" transform="scale(2)") + path(class='wifi-path0') + path(class='wifi-path1') + span(id="wifi-signal-text") — tr th Cellular Modem Signal Strength td - div(id="modem-signal-strength", style="display:inline-flex; align-itmes:flex-end; justify-content:flex-end; width:auto; height:35px; padding:4px") - svg(id='modem-icon' xmlns="http://www.w3.org/2000/svg" style="width:50; height:30; fill:none;") - g(class="modem-scale" transform="scale(2)") - path(class='modem-path0') + div(style="display:inline-flex; align-items:center; gap:10px") + div(id="modem-signal-strength", style="display:inline-flex; align-itmes:flex-end; justify-content:flex-end; width:auto; height:35px; padding:4px") + svg(id='modem-icon' xmlns="http://www.w3.org/2000/svg" style="width:50; height:30; fill:none;") + g(class="modem-scale" transform="scale(2)") + path(class='modem-path0') + span(id="modem-signal-text") — + tr + th Cellular Network Provider + td + span(id="modem-carrier") — h2 System Versioning Details @@ -179,11 +187,26 @@ html(xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en") button(class="btn btn-sm btn-primary btn-block", id="disable-modem") Disable Modem tr th(colspan=2) WiFi + tr + td WiFi IP Address + td + span(id="wifi-ip-address") — tr td button(class="btn btn-sm btn-primary btn-block", id="enable-wifi") Enable Wifi td button(class="btn btn-sm btn-primary btn-block", id="disable-wifi") Disable Wifi + tr + td(colspan=2) + p(style="margin-bottom:6px") Connect to a WiFi network (click Enable Wifi first): + select(id="wifi-ssid-list", class="form-control form-control-sm", style="margin-bottom:6px") + option(value="") — click to scan, or type below — + input(type="text", id="wifi-ssid", class="form-control form-control-sm", placeholder="SSID (network name)", autocomplete="off", style="margin-bottom:6px") + input(type="password", id="wifi-psk", class="form-control form-control-sm", placeholder="Password (leave blank for an open network)", autocomplete="new-password", style="margin-bottom:6px") + div(class="form-check", style="margin-bottom:6px") + input(type="checkbox", id="wifi-psk-show", class="form-check-input") + label(class="form-check-label", for="wifi-psk-show") Show password + button(class="btn btn-sm btn-primary btn-block", id="wifi-connect") Connect h2 Radio Software Control p You can restart the radio software with this control, which will require a page refresh after it is complete. table(class="table table-bordered table-sm table-dark") diff --git a/src/station-lcd-interface/menu-manager.js b/src/station-lcd-interface/menu-manager.js index f94bf69d..cc203c12 100644 --- a/src/station-lcd-interface/menu-manager.js +++ b/src/station-lcd-interface/menu-manager.js @@ -152,7 +152,12 @@ class MenuManager { autoRefresh_(enable) { clearTimeout(this.refresh_) if (enable == true) { - if (typeof this.focus.view.autoRefresh !== 'undefined') { + // Guard against focus having moved to a view-less menu node (or been + // popped) while a refresh cycle was in flight. Without this, a refresh + // that resolves/rejects after navigation dereferences a null `view` and + // throws, clearing the timer without re-arming it — which permanently + // freezes the display on its last frame. + if (this.focus && this.focus.view && typeof this.focus.view.autoRefresh !== 'undefined') { if (this.focus.view.autoRefresh > 0) { this.refresh_ = setTimeout(() => { this.view_(true) diff --git a/src/station-utils/uploader.py b/src/station-utils/uploader.py index 3c0ebe04..4aa9c43c 100755 --- a/src/station-utils/uploader.py +++ b/src/station-utils/uploader.py @@ -18,13 +18,20 @@ def __init__(self): self.base_uploaded_dir = os.path.join('/', 'data', 'uploaded') self.ctt_uploaded_dir = os.path.join(self.base_uploaded_dir, 'ctt') self.sg_uploaded_dir = os.path.join(self.base_uploaded_dir, 'sg') + self.failed_dir = os.path.join('/', 'data', 'rotated-failed') self.hardware_server_port = 3000 self.internet_check_ping_count = 3 self.ensureDirs() self.station_id = self.getStationId() self.station_config_file = '/etc/ctt/station-config.json' - self.TIMEOUT = 20 + # The per-request timeout is scaled to the file size: a large file on a + # slow uplink must not trip a fixed timeout on every attempt (a single + # 42 MB file at ~0.5 MB/s needs ~80 s, far past the old flat 20 s). We + # budget a base plus a conservative floor throughput; the real transfer + # usually finishes well inside this ceiling. + self.BASE_TIMEOUT = 60 # seconds, floor for any upload + self.MIN_UPLOAD_BYTES_PER_SEC = 100 * 1024 # pessimistic uplink for timeout budgeting self.MAX_ATTEMPTS = 3 self.attempt = 0 @@ -35,6 +42,7 @@ def getStationId(self): def ensureDirs(self): os.makedirs(self.ctt_uploaded_dir, exist_ok=True) os.makedirs(self.sg_uploaded_dir, exist_ok=True) + os.makedirs(self.failed_dir, exist_ok=True) def checkInternetStatus(self): url = 'https://station.internetofwildlife.com/status' @@ -49,10 +57,10 @@ def checkInternetStatus(self): return True return False - def post(self, endpoint, headers, data): + def post(self, endpoint, headers, data, timeout): self.attempt += 1 try: - response = requests.post(endpoint, headers=headers, data=data, timeout=self.TIMEOUT) + response = requests.post(endpoint, headers=headers, data=data, timeout=timeout) # check for a 204 response code for validation if response.status_code == 204: print('SUCCESS after {} tries'.format(self.attempt)) @@ -68,7 +76,7 @@ def post(self, endpoint, headers, data): print('exceeding attempts to upload file') return False else: - return self.post(endpoint, headers, data) + return self.post(endpoint, headers, data, timeout) def uploadFile(self, fileuri, filetype): endpoint = self.endpoint @@ -76,13 +84,27 @@ def uploadFile(self, fileuri, filetype): endpoint = '{}/sg'.format(endpoint) else: endpoint = '{}/ctt'.format(endpoint) + # fresh attempt budget per file (a prior file's exhausted attempts must + # not carry over and fail this one immediately) + self.attempt = 0 with open(fileuri, 'rb') as inFile: contents = inFile.read() + timeout = self.BASE_TIMEOUT + int(len(contents) / self.MIN_UPLOAD_BYTES_PER_SEC) headers = { 'filename': os.path.basename(fileuri), 'Content-Type': 'application/octet-stream' } - return self.post(endpoint, headers=headers, data=contents) + return self.post(endpoint, headers=headers, data=contents, timeout=timeout) + + def quarantineFile(self, fileuri): + # a file the server keeps rejecting (or that cannot finish within its + # size-scaled timeout) is moved aside so it stops blocking the queue. + # Only called once we've confirmed the internet is still up, so this is + # not triggered by a transient outage. + os.makedirs(self.failed_dir, exist_ok=True) + newuri = os.path.join(self.failed_dir, os.path.basename(fileuri)) + print('quarantining un-uploadable file', os.path.basename(fileuri), 'to', newuri) + shutil.move(fileuri, newuri) def rotateUploaded(self, fileuri, filetype): basename = os.path.basename(fileuri) @@ -104,9 +126,16 @@ def uploadAllCttFiles(self): for filename in sorted(filenames): res = self.uploadFile(fileuri=filename, filetype='ctt') if res is False: - # if we cannot upload a file - don't upload the rest - print('problem uploading these files - stopping upload process') - return False + # A failed file must not permanently block the queue behind + # it. Distinguish the two causes: if the internet dropped, + # stop and retry the whole batch next run; if we're still + # online the file itself is the problem, so quarantine it + # and keep draining the rest. + if self.checkInternetStatus() is False: + print('lost internet connection - stopping upload, will retry next run') + return False + self.quarantineFile(filename) + continue self.rotateUploaded(fileuri=filename, filetype='ctt') return True else: @@ -125,8 +154,13 @@ def uploadAllSgFiles(self): # upload files older than 1 hour res = self.uploadFile(fileuri=filename, filetype='sg') if res is False: - print('problem uploading files - aborting upload') - return False + # same policy as CTT: a real outage stops the run; a + # single bad file is quarantined so it stops blocking. + if self.checkInternetStatus() is False: + print('lost internet connection - stopping upload, will retry next run') + return False + self.quarantineFile(filename) + continue self.rotateUploaded(fileuri=filename, filetype='sg') return True else: diff --git a/system/native/ctt-lcd.version b/system/native/ctt-lcd.version index 1d0ba9ea..267577d4 100644 --- a/system/native/ctt-lcd.version +++ b/system/native/ctt-lcd.version @@ -1 +1 @@ -0.4.0 +0.4.1 diff --git a/system/scripts/enable-wifi.sh b/system/scripts/enable-wifi.sh index 11e743a9..996961ec 100755 --- a/system/scripts/enable-wifi.sh +++ b/system/scripts/enable-wifi.sh @@ -17,6 +17,15 @@ do modprobe $chipset done +# The modprobe above creates a fresh wlan0. NetworkManager/wpa_supplicant are +# still bound to the previous (now-removed) interface, so WiFi scans silently +# return nothing until they re-attach — which is why enabling WiFi used to need +# a reboot before a scan would work. Restart the two services to re-attach them +# to the new interface (no reboot required). +echo "restarting wpa_supplicant and NetworkManager to re-attach to wlan0" +sudo systemctl restart wpa_supplicant +sudo systemctl restart NetworkManager +