ESP32でRustを使ってWebSocket server
·
2min
Lチカが動いたので、WebSocket serverをRustで実装してみる。
Lチカの時と同様に雛形を作る。名前はws-serverにした
cargo generate --git https://github.com/esp-rs/esp-idf-template cargo
Cargo.tomlはanyhowだけ追加した。
[dependencies]
...
anyhow = "1"
esp-rsのサンプル集にhttp_ws_server.rsとhttp_ws_server_page.htmlがあるので、これをsrcの下にコピーした。http_ws_server.rsはmain.rsにリネーム。
このサンプル、なぜかESP32をWifiアクセスポイントにするという謎仕様なので修正した。
main.rsはこんな感じ。
use core::cmp::Ordering;
use core::convert::TryInto;
use std::net::Ipv4Addr;
use std::str::FromStr;
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::netif::{EspNetif, NetifConfiguration, NetifStack};
use esp_idf_svc::wifi::{WifiDriver, ClientConfiguration, Configuration as WifiConfiguration};
use esp_idf_svc::{
http::Method,
io::Write,
wifi::{AuthMethod},
ws::FrameType,
eventloop::EspSystemEventLoop,
http::server::EspHttpServer,
nvs::EspDefaultNvsPartition,
systime::EspSystemTime,
wifi::{BlockingWifi, EspWifi},
};
use esp_idf_svc::ipv4::{
ClientConfiguration as IpClientConfiguration, ClientSettings as IpClientSettings,
Configuration as IpConfiguration, Mask, Subnet,
};
use esp_idf_svc::sys::{EspError, ESP_ERR_INVALID_SIZE};
use log::*;
use std::{borrow::Cow, collections::BTreeMap, ffi::CStr, str, sync::Mutex};
const SSID: &str = env!("WIFI_SSID");
const PASSWORD: &str = env!("WIFI_PASS");
const DEVICE_IP: &str = env!("ESP_DEVICE_IP");
const GATEWAY_IP: &str = env!("GATEWAY_IP");
const GATEWAY_NETMASK: Option<&str> = option_env!("GATEWAY_NETMASK");
static INDEX_HTML: &str = include_str!("http_ws_server_page.html");
// Max payload length
const MAX_LEN: usize = 8;
// Need lots of stack to parse JSON
const STACK_SIZE: usize = 10240;
struct GuessingGame {
guesses: u32,
secret: u32,
done: bool,
}
impl GuessingGame {
fn new(secret: u32) -> Self {
Self {
guesses: 0,
secret,
done: false,
}
}
fn guess(&mut self, guess: u32) -> (Ordering, u32) {
if self.done {
(Ordering::Equal, self.guesses)
} else {
self.guesses += 1;
let cmp = guess.cmp(&self.secret);
if cmp == Ordering::Equal {
self.done = true;
}
(cmp, self.guesses)
}
}
fn parse_guess(input: &str) -> Option<u32> {
// Trim control codes (including null bytes) and/or whitespace
let Ok(number) = input
.trim_matches(|c: char| c.is_ascii_control() || c.is_whitespace())
.parse::<u32>()
else {
warn!("Not a number: `{input}` (length {})", input.len());
return None;
};
if !(1..=100).contains(&number) {
warn!("Not in range ({number})");
return None;
}
Some(number)
}
}
// Super rudimentary pseudo-random numbers
fn rand() -> u32 {
EspSystemTime::now(&EspSystemTime {}).subsec_nanos() / 65537
}
// Serialize numbers in English
fn nth(n: u32) -> Cow<'static, str> {
match n {
smaller @ (0..=13) => Cow::Borrowed(match smaller {
0 => "zeroth",
1 => "first",
2 => "second",
3 => "third",
4 => "fourth",
5 => "fifth",
6 => "sixth",
7 => "seventh",
8 => "eighth",
9 => "ninth",
10 => "10th",
11 => "11th",
12 => "12th",
13 => "13th",
_ => unreachable!(),
}),
larger => Cow::Owned(match larger % 10 {
1 => format!("{larger}st"),
2 => format!("{larger}nd"),
3 => format!("{larger}rd"),
_ => format!("{larger}th"),
}),
}
}
fn main() -> anyhow::Result<()> {
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
let peripherals = Peripherals::take()?;
let sys_loop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;
let wifi = WifiDriver::new(peripherals.modem, sys_loop.clone(), Some(nvs))?;
let wifi = configure_wifi(wifi)?;
let mut wifi = BlockingWifi::wrap(wifi, sys_loop)?;
connect_wifi(&mut wifi)?;
let mut server = create_server()?;
server.fn_handler("/", Method::Get, |req| {
req.into_ok_response()?
.write_all(INDEX_HTML.as_bytes())
.map(|_| ())
})?;
let guessing_games = Mutex::new(BTreeMap::<i32, GuessingGame>::new());
server.ws_handler("/ws/guess", move |ws| {
let mut sessions = guessing_games.lock().unwrap();
if ws.is_new() {
sessions.insert(ws.session(), GuessingGame::new((rand() % 100) + 1));
info!("New WebSocket session ({} open)", sessions.len());
ws.send(
FrameType::Text(false),
"Welcome to the guessing game! Enter a number between 1 and 100".as_bytes(),
)?;
return Ok(());
} else if ws.is_closed() {
sessions.remove(&ws.session());
info!("Closed WebSocket session ({} open)", sessions.len());
return Ok(());
}
let session = sessions.get_mut(&ws.session()).unwrap();
// NOTE: Due to the way the underlying C implementation works, ws.recv()
// may only be called with an empty buffer exactly once to receive the
// incoming buffer size, then must be called exactly once to receive the
// actual payload.
let (_frame_type, len) = match ws.recv(&mut []) {
Ok(frame) => frame,
Err(e) => return Err(e),
};
if len > MAX_LEN {
ws.send(FrameType::Text(false), "Request too big".as_bytes())?;
ws.send(FrameType::Close, &[])?;
return Err(EspError::from_infallible::<ESP_ERR_INVALID_SIZE>());
}
let mut buf = [0; MAX_LEN]; // Small digit buffer can go on the stack
ws.recv(buf.as_mut())?;
let Ok(user_string) = CStr::from_bytes_until_nul(&buf[..len]) else {
ws.send(FrameType::Text(false), "[CStr decode Error]".as_bytes())?;
return Ok(());
};
let Ok(user_string) = user_string.to_str() else {
ws.send(FrameType::Text(false), "[UTF-8 Error]".as_bytes())?;
return Ok(());
};
let Some(user_guess) = GuessingGame::parse_guess(user_string) else {
ws.send(
FrameType::Text(false),
"Please enter a number between 1 and 100".as_bytes(),
)?;
return Ok(());
};
match session.guess(user_guess) {
(Ordering::Greater, n) => {
let reply = format!("Your {} guess was too high", nth(n));
ws.send(FrameType::Text(false), reply.as_ref())?;
}
(Ordering::Less, n) => {
let reply = format!("Your {} guess was too low", nth(n));
ws.send(FrameType::Text(false), reply.as_ref())?;
}
(Ordering::Equal, n) => {
let reply = format!(
"You guessed {} on your {} try! Refresh to play again",
session.secret,
nth(n)
);
ws.send(FrameType::Text(false), reply.as_ref())?;
ws.send(FrameType::Close, &[])?;
}
}
Ok::<(), EspError>(())
})?;
loop {
std::thread::sleep(std::time::Duration::from_secs(60));
}
}
fn configure_wifi(wifi: WifiDriver) -> anyhow::Result<EspWifi> {
let netmask = GATEWAY_NETMASK.unwrap_or("24");
let netmask = u8::from_str(netmask)?;
let gateway_addr = Ipv4Addr::from_str(GATEWAY_IP)?;
let static_ip = Ipv4Addr::from_str(DEVICE_IP)?;
let mut wifi = EspWifi::wrap_all(
wifi,
EspNetif::new_with_conf(&NetifConfiguration {
ip_configuration: Some(IpConfiguration::Client(IpClientConfiguration::Fixed(
IpClientSettings {
ip: static_ip,
subnet: Subnet {
gateway: gateway_addr,
mask: Mask(netmask),
},
// Can also be set to Ipv4Addrs if you need DNS
dns: None,
secondary_dns: None,
},
))),
..NetifConfiguration::wifi_default_client()
})?,
EspNetif::new(NetifStack::Ap)?,
)?;
let wifi_configuration = WifiConfiguration::Client(ClientConfiguration {
ssid: SSID.try_into().unwrap(),
bssid: None,
auth_method: AuthMethod::WPA2Personal,
password: PASSWORD.try_into().unwrap(),
channel: None,
..Default::default()
});
wifi.set_configuration(&wifi_configuration)?;
Ok(wifi)
}
fn connect_wifi(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<(), esp_idf_svc::sys::EspError> {
let wifi_configuration: esp_idf_svc::wifi::Configuration = esp_idf_svc::wifi::Configuration::Client(esp_idf_svc::wifi::ClientConfiguration {
ssid: SSID.try_into().unwrap(),
bssid: None,
auth_method: AuthMethod::WPA2Personal,
password: PASSWORD.try_into().unwrap(),
channel: None,
..Default::default()
});
wifi.set_configuration(&wifi_configuration)?;
wifi.start()?;
info!("Wifi started");
wifi.connect()?;
info!("Wifi connected");
wifi.wait_netif_up()?;
info!("Wifi netif up");
Ok(())
}
fn create_server() -> anyhow::Result<EspHttpServer<'static>> {
let server_configuration = esp_idf_svc::http::server::Configuration {
stack_size: STACK_SIZE,
..Default::default()
};
Ok(EspHttpServer::new(&server_configuration)?)
}
Wi-FiのSSID/パスワードなどは例によってビルドの時に環境変数で渡すので、これらの環境変数を設定してからcargo buildする。DNSは指定していないけど、必要なら同じように環境変数から渡せば良い。
const SSID: &str = env!("ESP32_WIFI_SSID");
const PASSWORD: &str = env!("ESP32_WIFI_PASS");
const DEVICE_IP: &str = env!("ESP_DEVICE_IP");
const GATEWAY_IP: &str = env!("GATEWAY_IP");
const GATEWAY_NETMASK: Option<&str> = option_env!("GATEWAY_NETMASK");
デフォルトのままだと、こんなエラーになる。
error[E0599]: no method named `ws_handler` found for struct `EspHttpServer` in the current scope
--> src/main.rs:134:12
|
134 | server.ws_handler("/ws/guess", None, move |ws| {
| -------^^^^^^^^^^
|
help: there is a method `handler` with a similar name
|
134 - server.ws_handler("/ws/guess", None, move |ws| {
134 + server.handler("/ws/guess", None, move |ws| {
|
sdkconfig.defaultsに以下の記載を追加してやれば解決する。
CONFIG_HTTPD_WS_SUPPORT=y
コアがesp32の場合は、アプリケーションのサイズが1MBを越えていても特に設定を変えなくて良いようだ。起動したらhttp://<<<DEVICE_IP>>>をブラウザで開く。

簡単な数当てゲームが動くので、二分法で当てよう(笑)。
ソース全体はこちら
るいもの戯言