WiFi STA to home network, configurable UDP address in Cue, JSON session export/import

This commit is contained in:
jess 2026-03-31 20:23:55 -07:00
parent f36989e3f9
commit 4beb9f4408
8 changed files with 181 additions and 6 deletions

View File

@ -5,7 +5,7 @@ export IDF_PYTHON_ENV_PATH := $(HOME)/.espressif/python_env/idf6.0_py3.12_env
IDF = . $(IDF_PATH)/export.sh > /dev/null 2>&1 && idf.py
.PHONY: all flash monitor clean menuconfig size erase select
.PHONY: all flash monitor clean menuconfig size erase select fcf
all:
$(IDF) build
@ -28,6 +28,10 @@ size:
erase:
$(IDF) -p $(PORT) erase-flash
fcf:
rm -rf build sdkconfig
$(IDF) -p $(PORT) flash monitor
select:
@devs=($$(ls /dev/cu.usb* 2>/dev/null)); \
if [ $${#devs[@]} -eq 0 ]; then \

22
cue/Cargo.lock generated
View File

@ -761,6 +761,7 @@ checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300"
name = "cue"
version = "0.1.0"
dependencies = [
"dirs-next",
"futures",
"iced",
"midir",
@ -836,6 +837,16 @@ dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
@ -847,6 +858,17 @@ dependencies = [
"winapi",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]]
name = "dispatch"
version = "0.2.0"

View File

@ -12,6 +12,7 @@ muda = { version = "0.17", default-features = false }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs-next = "2"
[target.'cfg(windows)'.build-dependencies]
winres = "0.1"

View File

@ -127,6 +127,7 @@ pub enum Message {
OpenMidiSetup,
RefreshMidi,
ToggleTransport,
UdpAddrChanged(String),
}
pub struct App {
@ -224,6 +225,7 @@ pub struct App {
temp_c: f32,
midi_gen: u64,
transport: TransportMode,
udp_addr: String,
}
/* ---- data table formatting ---- */
@ -418,6 +420,7 @@ impl App {
temp_c: 25.0,
midi_gen: 0,
transport: TransportMode::Midi,
udp_addr: crate::udp::load_addr(),
}, Task::none())
}
@ -1023,26 +1026,32 @@ impl App {
self.ble_connected = false;
self.status = match self.transport {
TransportMode::Midi => "Looking for MIDI device...".into(),
TransportMode::Udp => "Connecting UDP...".into(),
TransportMode::Udp => format!("Connecting UDP to {}...", self.udp_addr),
};
}
Message::UdpAddrChanged(s) => {
self.udp_addr = s;
}
}
Task::none()
}
pub fn subscription(&self) -> Subscription<Message> {
let use_udp = self.transport == TransportMode::Udp;
let udp_addr = self.udp_addr.clone();
let transport = Subscription::run_with_id(
self.midi_gen,
iced::stream::channel(100, move |mut output| async move {
if use_udp {
let addr = udp_addr.clone();
loop {
let (udp_tx, mut udp_rx) = mpsc::unbounded_channel::<UdpEvent>();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let tx = udp_tx.clone();
let a = addr.clone();
tokio::spawn(async move {
if let Err(e) = crate::udp::connect_and_run(tx, cmd_rx).await {
if let Err(e) = crate::udp::connect_and_run(tx, cmd_rx, a).await {
eprintln!("UDP: {e}");
}
});
@ -1223,6 +1232,15 @@ impl App {
.padding([4, 10])
.on_press(Message::ToggleTransport),
);
if self.transport == TransportMode::Udp {
status_row = status_row.push(
text_input("IP:port", &self.udp_addr)
.size(12)
.width(160)
.on_input(Message::UdpAddrChanged)
.on_submit(Message::ToggleTransport), // reconnect on enter
);
}
if !connected && self.transport == TransportMode::Midi {
status_row = status_row.push(
button(text("Refresh MIDI").size(11))

View File

@ -1,4 +1,5 @@
use rusqlite::{Connection, params};
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone)]
#[allow(dead_code)]
@ -159,6 +160,68 @@ impl Storage {
})?;
rows.collect()
}
pub fn export_session(&self, session_id: i64) -> Result<String, Box<dyn std::error::Error>> {
let sess = self.conn.query_row(
"SELECT id, name, notes, created_at FROM sessions WHERE id = ?1",
params![session_id],
|row| Ok(Session {
id: row.get(0)?, name: row.get(1)?,
notes: row.get(2)?, created_at: row.get(3)?,
}),
)?;
let measurements = self.get_measurements(session_id)?;
let mut export_measurements = Vec::new();
for m in &measurements {
let points = self.get_data_points(m.id)?;
let data: Vec<serde_json::Value> = points.iter()
.map(|p| serde_json::from_str(&p.data_json).unwrap_or(serde_json::Value::Null))
.collect();
export_measurements.push(ExportMeasurement {
mtype: m.mtype.clone(),
params: serde_json::from_str(&m.params_json).unwrap_or_default(),
created_at: m.created_at.clone(),
data,
});
}
let export = ExportSession {
name: sess.name,
notes: sess.notes,
created_at: sess.created_at,
measurements: export_measurements,
};
Ok(serde_json::to_string_pretty(&export)?)
}
pub fn import_session(&self, json: &str) -> Result<i64, Box<dyn std::error::Error>> {
let export: ExportSession = serde_json::from_str(json)?;
let session_id = self.create_session(&export.name, &export.notes)?;
for m in &export.measurements {
let params_json = serde_json::to_string(&m.params)?;
let mid = self.create_measurement(session_id, &m.mtype, &params_json)?;
let points: Vec<(i32, String)> = m.data.iter().enumerate()
.map(|(i, v)| (i as i32, serde_json::to_string(v).unwrap_or_default()))
.collect();
self.add_data_points_batch(mid, &points)?;
}
Ok(session_id)
}
}
#[derive(Serialize, Deserialize)]
struct ExportSession {
name: String,
notes: String,
created_at: String,
measurements: Vec<ExportMeasurement>,
}
#[derive(Serialize, Deserialize)]
struct ExportMeasurement {
mtype: String,
params: serde_json::Value,
created_at: String,
data: Vec<serde_json::Value>,
}
fn dirs() -> std::path::PathBuf {

View File

@ -4,7 +4,25 @@ use std::time::{Duration, Instant};
use crate::protocol::{self, EisMessage};
const ESP_ADDR: &str = "192.168.4.1:5941";
const DEFAULT_ADDR: &str = "192.168.4.1:5941";
const SETTINGS_FILE: &str = ".eis4_udp_addr";
pub fn load_addr() -> String {
let path = dirs_next::home_dir()
.map(|h| h.join(SETTINGS_FILE))
.unwrap_or_default();
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_ADDR.to_string())
}
pub fn save_addr(addr: &str) {
if let Some(path) = dirs_next::home_dir().map(|h| h.join(SETTINGS_FILE)) {
let _ = std::fs::write(path, addr);
}
}
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
const TIMEOUT: Duration = Duration::from_secs(10);
@ -33,9 +51,13 @@ fn extract_sysex_frames(buf: &[u8]) -> Vec<Vec<u8>> {
pub async fn connect_and_run(
tx: mpsc::UnboundedSender<UdpEvent>,
mut cmd_rx: mpsc::UnboundedReceiver<Vec<u8>>,
addr: String,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let esp_addr = if addr.contains(':') { addr.clone() } else { format!("{addr}:5941") };
save_addr(&esp_addr);
loop {
let _ = tx.send(UdpEvent::Status("Connecting UDP...".into()));
let _ = tx.send(UdpEvent::Status(format!("Connecting UDP to {esp_addr}...")));
let sock = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
@ -46,7 +68,7 @@ pub async fn connect_and_run(
}
};
if let Err(e) = sock.connect(ESP_ADDR).await {
if let Err(e) = sock.connect(&esp_addr).await {
let _ = tx.send(UdpEvent::Status(format!("Connect failed: {e}")));
tokio::time::sleep(Duration::from_secs(2)).await;
continue;

View File

@ -1,3 +1,9 @@
idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "ble.c" "wifi_transport.c" "temp.c" "refs.c"
INCLUDE_DIRS "."
REQUIRES ad5941 ad5941_port bt nvs_flash esp_wifi esp_netif esp_event)
if(DEFINED ENV{WIFI_SSID})
target_compile_definitions(${COMPONENT_LIB} PRIVATE
STA_SSID="$ENV{WIFI_SSID}"
STA_PASS="$ENV{WIFI_PASS}")
endif()

View File

@ -197,9 +197,30 @@ static void wifi_event_handler(void *arg, esp_event_base_t base,
}
}
#ifndef STA_SSID
#define STA_SSID ""
#endif
#ifndef STA_PASS
#define STA_PASS ""
#endif
static void sta_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
(void)arg;
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
printf("WiFi: STA disconnected, reconnecting...\n");
esp_wifi_connect();
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *evt = (ip_event_got_ip_t *)data;
printf("WiFi: STA connected, IP " IPSTR "\n", IP2STR(&evt->ip_info.ip));
}
}
static int wifi_ap_init(void)
{
esp_netif_create_default_wifi_ap();
esp_netif_create_default_wifi_sta();
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
esp_err_t err = esp_wifi_init(&wifi_cfg);
@ -208,6 +229,10 @@ static int wifi_ap_init(void)
esp_event_handler_instance_t inst;
esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
wifi_event_handler, NULL, &inst);
esp_event_handler_instance_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED,
sta_event_handler, NULL, &inst);
esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
sta_event_handler, NULL, &inst);
wifi_config_t ap_cfg = {
.ap = {
@ -222,9 +247,23 @@ static int wifi_ap_init(void)
esp_wifi_set_mode(WIFI_MODE_APSTA);
esp_wifi_set_config(WIFI_IF_AP, &ap_cfg);
if (strlen(STA_SSID) > 0) {
wifi_config_t sta_cfg = {0};
strncpy((char *)sta_cfg.sta.ssid, STA_SSID, sizeof(sta_cfg.sta.ssid) - 1);
strncpy((char *)sta_cfg.sta.password, STA_PASS, sizeof(sta_cfg.sta.password) - 1);
sta_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
esp_wifi_set_config(WIFI_IF_STA, &sta_cfg);
}
err = esp_wifi_start();
if (err) return err;
if (strlen(STA_SSID) > 0) {
esp_wifi_connect();
printf("WiFi: STA connecting to \"%s\"\n", STA_SSID);
}
printf("WiFi: AP \"%s\" on channel %d\n", WIFI_SSID, WIFI_CHANNEL);
return 0;
}