Add Wi-Fi scan details display and MQTT publish

- HTML: Add Wi-Fi scan results container to network.html
- Rust: Implement `wifi_scan_details()` with RSSI, channel, auth method
- API & UI: Fetch and display scan results in table format
- MQTT: Publish top 10 networks sorted by RSSI to `/wifi_scan`
This commit is contained in:
2026-05-28 00:46:14 +02:00
parent 3618b3329c
commit 6b419dba6c
10 changed files with 180 additions and 10 deletions

View File

@@ -7,15 +7,16 @@ use chrono::{DateTime, Utc};
use crate::fat_error::{FatError, FatResult}; use crate::fat_error::{FatError, FatResult};
use crate::hal::shared_flash::MutexFlashStorage; use crate::hal::shared_flash::MutexFlashStorage;
use alloc::string::ToString; use alloc::string::{String, ToString};
use alloc::sync::Arc; use alloc::sync::Arc;
use alloc::{string::String, vec, vec::Vec}; use alloc::{format, vec, vec::Vec};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex; use embassy_sync::mutex::Mutex;
use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash, RmwNorFlashStorage}; use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash, RmwNorFlashStorage};
use esp_bootloader_esp_idf::ota::OtaImageState::Valid; use esp_bootloader_esp_idf::ota::OtaImageState::Valid;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState}; use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
use esp_bootloader_esp_idf::partitions::{AppPartitionSubType, FlashRegion}; use esp_bootloader_esp_idf::partitions::{AppPartitionSubType, FlashRegion};
use serde::{Deserialize, Serialize};
use esp_hal::gpio::{Input, RtcPinWithResistors}; use esp_hal::gpio::{Input, RtcPinWithResistors};
use esp_hal::rng::Rng; use esp_hal::rng::Rng;
use esp_hal::rtc_cntl::{ use esp_hal::rtc_cntl::{
@@ -30,6 +31,24 @@ use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
use esp_radio::wifi::{Interface, WifiController}; use esp_radio::wifi::{Interface, WifiController};
use log::{error, info}; use log::{error, info};
/// Detailed Wi-Fi scan information including signal strength
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct WifiScanDetails {
pub ssid: String,
pub bssid: String,
pub rssi: i32,
pub channel: u8,
pub auth_method: String,
}
// Helper function to format BSSID as MAC address string
fn format_bssid(bssid: &[u8; 6]) -> String {
alloc::format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]
)
}
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))] #[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT]; static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT];
@@ -192,6 +211,25 @@ impl Esp<'_> {
Ok(rv) Ok(rv)
} }
/// Return detailed Wi-Fi scan information including signal strength
pub(crate) async fn wifi_scan_details(&mut self) -> FatResult<Vec<WifiScanDetails>> {
let ap_infos = self.wifi_scan().await?;
// Convert AccessPointInfo to WifiScanDetails
let details: Vec<WifiScanDetails> = ap_infos
.iter()
.map(|ap| WifiScanDetails {
ssid: ap.ssid.as_str().to_string(),
bssid: format_bssid(&ap.bssid),
rssi: ap.signal_strength as i32,
channel: ap.channel as u8,
auth_method: format!("{:?}",ap.auth_method),
})
.collect();
Ok(details)
}
pub(crate) fn last_pump_time(&self, plant: usize) -> Option<DateTime<Utc>> { pub(crate) fn last_pump_time(&self, plant: usize) -> Option<DateTime<Utc>> {
let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant]; let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant];
DateTime::from_timestamp_millis(ts) DateTime::from_timestamp_millis(ts)

View File

@@ -280,6 +280,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
error!("Error publishing battery state {e}"); error!("Error publishing battery state {e}");
}); });
let _ = mqtt::publish_mppt_state(&mut board).await; let _ = mqtt::publish_mppt_state(&mut board).await;
let _ = mqtt::publish_wifi_scan(&mut board).await;
} }
log( log(

View File

@@ -6,8 +6,9 @@ use crate::log::{log, LogMessage};
use crate::plant_state::PlantState; use crate::plant_state::PlantState;
use crate::tank::TankState; use crate::tank::TankState;
use crate::{bail, VersionInfo}; use crate::{bail, VersionInfo};
use alloc::string::String; use alloc::format;
use alloc::{format, string::ToString}; use alloc::string::{String, ToString};
use alloc::vec::Vec;
use chrono::DateTime; use chrono::DateTime;
use chrono_tz::Tz; use chrono_tz::Tz;
use core::sync::atomic::Ordering; use core::sync::atomic::Ordering;
@@ -349,6 +350,16 @@ pub async fn pump_info(
}; };
} }
/// Wi-Fi scan result details for MQTT
#[derive(Serialize, Debug, PartialEq)]
pub struct WifiScanResult {
pub ssid: String,
pub bssid: String,
pub rssi: i32,
pub channel: u8,
pub auth_method: String,
}
#[derive(Serialize, Debug, PartialEq)] #[derive(Serialize, Debug, PartialEq)]
pub struct Solar { pub struct Solar {
pub current_ma: u32, pub current_ma: u32,
@@ -407,3 +418,30 @@ pub async fn publish_battery_state(
publish("/battery", &json).await; publish("/battery", &json).await;
Ok(()) Ok(())
} }
/// Publish Wi-Fi scan details to MQTT
pub async fn publish_wifi_scan(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let mut wifi_details = board.board_hal.get_esp().wifi_scan_details().await?;
// Sort by RSSI in descending order (strongest first)
wifi_details.sort_by(|a, b| b.rssi.cmp(&a.rssi));
// Take only the strongest 10 results
let wifi_results: Vec<WifiScanResult> = wifi_details
.iter()
.take(10)
.map(|d| WifiScanResult {
ssid: d.ssid.clone(),
bssid: d.bssid.clone(),
rssi: d.rssi,
channel: d.channel,
auth_method: d.auth_method.clone(),
})
.collect();
let json = serde_json::to_string(&wifi_results)?;
publish("/wifi_scan", &json).await;
Ok(())
}

View File

@@ -214,3 +214,12 @@ pub(crate) async fn get_log_localization_config<T, const N: usize>(
&LogMessage::log_localisation_config(), &LogMessage::log_localisation_config(),
)?)) )?))
} }
/// Return Wi-Fi scan details including signal strength (RSSI)
pub(crate) async fn get_wifi_details<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;
let wifi_details = board.board_hal.get_esp().wifi_scan_details().await?;
Ok(Some(serde_json::to_string(&wifi_details)?))
}

View File

@@ -12,6 +12,7 @@ use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_co
use crate::webserver::get_json::{ use crate::webserver::get_json::{
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config, delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
get_firmware_info_web, get_solar_state, get_time, get_timezones, list_saves, tank_info, get_firmware_info_web, get_solar_state, get_time, get_timezones, list_saves, tank_info,
get_wifi_details,
}; };
use crate::webserver::get_log::{get_live_log, get_log}; use crate::webserver::get_log::{get_live_log, get_log};
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index}; use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
@@ -83,6 +84,7 @@ impl Handler for HTTPRequestRouter {
"/timezones" => Some(get_timezones().await), "/timezones" => Some(get_timezones().await),
"/moisture" => Some(get_live_moisture(conn).await), "/moisture" => Some(get_live_moisture(conn).await),
"/list_saves" => Some(list_saves(conn).await), "/list_saves" => Some(list_saves(conn).await),
"/wifi_details" => Some(get_wifi_details(conn).await),
// /live_log accepts an optional ?after=N query parameter // /live_log accepts an optional ?after=N query parameter
p if p == "/live_log" || p.starts_with("/live_log?") => { p if p == "/live_log" || p.starts_with("/live_log?") => {
let after: Option<u64> = p let after: Option<u64> = p

View File

@@ -225,6 +225,15 @@ export interface Detection {
plant: DetectionPlant[] plant: DetectionPlant[]
} }
/// Wi-Fi scan result details for UI display
export interface WifiScanResult {
ssid: string,
bssid: string,
rssi: number, // signal strength in dBm
channel: number,
auth_method: string
}
export interface TankInfo { export interface TankInfo {
/// there is enough water in the tank /// there is enough water in the tank
enough_water: boolean, enough_water: boolean,

View File

@@ -37,7 +37,7 @@
} }
#logpanel { #logpanel {
display: none;
} }
</style> </style>

View File

@@ -26,7 +26,7 @@ import {
Moistures, Moistures,
NightLampCommand, NightLampCommand,
PlantControllerConfig, PlantControllerConfig,
SetTime, SSIDList, TankInfo, SetTime, SSIDList, TankInfo, WifiScanResult,
TestPump, TestPump,
VersionInfo, VersionInfo,
SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower
@@ -172,6 +172,21 @@ export class Controller {
} }
} }
async scanWifiDetails(): Promise<void> {
try {
const response = await fetch(PUBLIC_URL + "/wifi_details");
if (response.ok) {
const wifiDetails = await response.json();
controller.networkView.displayWifiResults(wifiDetails as WifiScanResult[]);
} else {
toast.error(`Failed to fetch Wi-Fi details: ${response.status}`);
}
} catch (error) {
toast.error(`Wi-Fi details error: ${error}`);
console.log(error);
}
}
uploadNewFirmware(file: File) { uploadNewFirmware(file: File) {
let current = 0; let current = 0;
let max = 100; let max = 100;
@@ -501,6 +516,8 @@ export class Controller {
if (ajax.status >= 200 && ajax.status < 300) { if (ajax.status >= 200 && ajax.status < 300) {
this.networkView.setScanResult(ajax.response as SSIDList); this.networkView.setScanResult(ajax.response as SSIDList);
toast.success("WiFi scan completed"); toast.success("WiFi scan completed");
// Also fetch detailed Wi-Fi information
this.scanWifiDetails();
} else { } else {
toast.error(`WiFi scan failed: ${ajax.status}`); toast.error(`WiFi scan failed: ${ajax.status}`);
} }

View File

@@ -85,7 +85,15 @@
<input class="mqttvalue" type="text" id="mqtt_password" placeholder=""> <input class="mqttvalue" type="text" id="mqtt_password" placeholder="">
</div> </div>
</div> </div>
<div class="subcontainer">
<div class="flexcontainer">
<div class="subtitle">Wi-Fi Scan Results</div>
</div>
<div id="wifi-results">
<p>Scan for available networks to see signal strength</p>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,9 @@
import { Controller } from "./main"; import { Controller } from "./main";
import {NetworkConfig, SSIDList} from "./api"; import {NetworkConfig, SSIDList, WifiScanResult} from "./api";
export class NetworkConfigView { export class NetworkConfigView {
private wifiResults: HTMLElement;
setScanResult(ssidList: SSIDList) { setScanResult(ssidList: SSIDList) {
this.ssidlist.innerHTML = '' this.ssidlist.innerHTML = ''
for (const ssid of ssidList.ssids) { for (const ssid of ssidList.ssids) {
@@ -10,6 +12,47 @@ export class NetworkConfigView {
this.ssidlist.appendChild(wi); this.ssidlist.appendChild(wi);
} }
} }
async scanAndDisplayWifiDetails() {
try {
const response = await fetch('/wifi_details');
if (response.ok) {
const data: WifiScanResult[] = await response.json();
this.displayWifiResults(data);
}
} catch (error) {
console.error('Error fetching Wi-Fi details:', error);
this.displayWifiResults([]);
}
}
displayWifiResults(results: WifiScanResult[]) {
const wifiContainer = document.getElementById('wifi-results');
if (!wifiContainer) return;
if (results.length === 0) {
wifiContainer.innerHTML = '<p>No Wi-Fi networks found</p>';
return;
}
let html = '<table style="width:100%; border-collapse: collapse;">';
html += '<tr><th style="text-align:left; padding: 8px;">SSID</th>';
html += '<th style="text-align:left; padding: 8px;">Signal (RSSI)</th>';
html += '<th style="text-align:left; padding: 8px;">Channel</th>';
html += '<th style="text-align:left; padding: 8px;">Authentication</th></tr>';
results.forEach(result => {
html += '<tr style="border-bottom: 1px solid #ddd;">';
html += `<td style="padding: 8px;">${result.ssid}</td>`;
html += `<td style="padding: 8px;">${result.rssi} dBm</td>`;
html += `<td style="padding: 8px;">${result.channel}</td>`;
html += `<td style="padding: 8px;">${result.auth_method}</td>`;
html += '</tr>';
});
html += '</table>';
wifiContainer.innerHTML = html;
}
private readonly ap_ssid: HTMLInputElement; private readonly ap_ssid: HTMLInputElement;
private readonly ssid: HTMLInputElement; private readonly ssid: HTMLInputElement;
private readonly password: HTMLInputElement; private readonly password: HTMLInputElement;
@@ -47,9 +90,14 @@ export class NetworkConfigView {
this.ssidlist = document.getElementById("ssidlist") as HTMLElement this.ssidlist = document.getElementById("ssidlist") as HTMLElement
let scanWifiBtn = document.getElementById("scan") as HTMLButtonElement; let scanWifiBtn = document.getElementById("scan") as HTMLButtonElement;
scanWifiBtn.onclick = function (){ scanWifiBtn.onclick = async () => {
controller.scanWifi(); controller.scanWifi();
} // After Wi-Fi scan, fetch and display detailed results
await this.scanAndDisplayWifiDetails();
};
// Store wifiResults reference for later use
this.wifiResults = document.getElementById('wifi-results') as HTMLElement;
} }
setConfig(network: NetworkConfig) { setConfig(network: NetworkConfig) {