diff --git a/cue/src/app.rs b/cue/src/app.rs index d81e7ee..8cb4d40 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -1,5 +1,5 @@ use futures::SinkExt; -use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input}; +use iced::widget::{button, canvas, column, container, pick_list, row, scrollable, text, text_input}; use iced::{Element, Length, Subscription, Task, Theme}; use std::time::Duration; use tokio::sync::mpsc; @@ -222,7 +222,15 @@ impl App { ); } - let content = column![status, controls, scrollable(data_rows)] + let bode = canvas(crate::plot::BodePlot { points: &self.points }) + .width(Length::FillPortion(3)) + .height(300); + let nyquist = canvas(crate::plot::NyquistPlot { points: &self.points }) + .width(Length::FillPortion(2)) + .height(300); + let plots = row![bode, nyquist].spacing(10); + + let content = column![status, controls, plots, scrollable(data_rows)] .spacing(20) .padding(20); diff --git a/cue/src/main.rs b/cue/src/main.rs index a7699e8..9022914 100644 --- a/cue/src/main.rs +++ b/cue/src/main.rs @@ -1,5 +1,6 @@ mod app; mod ble; +mod plot; mod protocol; fn main() -> iced::Result { diff --git a/cue/src/plot.rs b/cue/src/plot.rs new file mode 100644 index 0000000..87987b5 --- /dev/null +++ b/cue/src/plot.rs @@ -0,0 +1,445 @@ +use iced::widget::canvas::{self, Event, Frame, Geometry, Path, Stroke, Text}; +use iced::{Color, Point, Rectangle, Renderer, Theme}; +use iced::mouse; + +use crate::app::Message; +use crate::protocol::EisPoint; + +const MARGIN_L: f32 = 55.0; +const MARGIN_R: f32 = 15.0; +const MARGIN_T: f32 = 15.0; +const MARGIN_B: f32 = 25.0; + +const COL_MAG: Color = Color { r: 0.3, g: 0.85, b: 1.0, a: 1.0 }; +const COL_PH: Color = Color { r: 1.0, g: 0.55, b: 0.2, a: 1.0 }; +const COL_NYQ: Color = Color { r: 0.4, g: 1.0, b: 0.4, a: 1.0 }; +const COL_GRID: Color = Color { r: 0.25, g: 0.25, b: 0.28, a: 1.0 }; +const COL_AXIS: Color = Color { r: 0.6, g: 0.6, b: 0.6, a: 1.0 }; +const COL_DIM: Color = Color { r: 0.4, g: 0.4, b: 0.4, a: 1.0 }; + +const ZOOM_FACTOR: f32 = 1.15; + +/* ---- View range ---- */ + +#[derive(Clone, Copy)] +struct Vr { + lo: f32, + hi: f32, +} + +impl Vr { + fn new(lo: f32, hi: f32) -> Self { Self { lo, hi } } + fn span(&self) -> f32 { self.hi - self.lo } + + fn zoom_at(&mut self, factor: f32, anchor_frac: f32) { + let anchor = self.lo + anchor_frac * self.span(); + let new_span = self.span() / factor; + self.lo = anchor - anchor_frac * new_span; + self.hi = anchor + (1.0 - anchor_frac) * new_span; + } + + fn pan_frac(&mut self, frac: f32) { + let d = frac * self.span(); + self.lo -= d; + self.hi -= d; + } +} + +fn screen_frac(pos: f32, lo: f32, hi: f32) -> f32 { + if (hi - lo).abs() < 1e-6 { 0.5 } else { (pos - lo) / (hi - lo) } +} + +/* ---- Drawing helpers ---- */ + +fn lerp(v: f32, lo: f32, hi: f32, out_lo: f32, out_hi: f32) -> f32 { + if (hi - lo).abs() < 1e-10 { return (out_lo + out_hi) / 2.0; } + out_lo + (v - lo) / (hi - lo) * (out_hi - out_lo) +} + +fn nice_step(range: f32, target_ticks: usize) -> f32 { + if range.abs() < 1e-10 { return 1.0; } + let rough = range / target_ticks as f32; + let mag = 10f32.powf(rough.abs().log10().floor()); + let norm = rough.abs() / mag; + let s = if norm < 1.5 { 1.0 } else if norm < 3.5 { 2.0 } else if norm < 7.5 { 5.0 } else { 10.0 }; + s * mag +} + +fn dl(frame: &mut Frame, a: Point, b: Point, color: Color, width: f32) { + frame.stroke(&Path::line(a, b), Stroke::default().with_color(color).with_width(width)); +} + +fn dt(frame: &mut Frame, pos: Point, txt: &str, color: Color, size: f32) { + frame.fill_text(Text { + content: txt.to_string(), position: pos, color, size: size.into(), + ..Text::default() + }); +} + +fn draw_polyline(frame: &mut Frame, pts: &[Point], color: Color, width: f32) { + if pts.len() < 2 { return; } + let path = Path::new(|b| { + b.move_to(pts[0]); + for p in &pts[1..] { b.line_to(*p); } + }); + frame.stroke(&path, Stroke::default().with_color(color).with_width(width)); +} + +fn draw_dots(frame: &mut Frame, pts: &[Point], color: Color, r: f32) { + for p in pts { frame.fill(&Path::circle(*p, r), color); } +} + +/* ---- Bode state: zoom/pan on frequency axis, Y auto-scales ---- */ + +#[derive(Default)] +pub struct BodeState { + freq: Option, + drag: Option<(f32, Vr)>, +} + +pub struct BodePlot<'a> { + pub points: &'a [EisPoint], +} + +impl BodePlot<'_> { + fn auto_freq(&self) -> Option { + if self.points.is_empty() { return None; } + let lo = self.points.iter().map(|p| p.freq_hz.log10()).fold(f32::INFINITY, f32::min); + let hi = self.points.iter().map(|p| p.freq_hz.log10()).fold(f32::NEG_INFINITY, f32::max); + let pad = (hi - lo).max(0.1) * 0.05; + Some(Vr::new(lo - pad, hi + pad)) + } +} + +impl<'a> canvas::Program for BodePlot<'a> { + type State = BodeState; + + fn update( + &self, state: &mut BodeState, event: Event, + bounds: Rectangle, cursor: mouse::Cursor, + ) -> (canvas::event::Status, Option) { + let Some(pos) = cursor.position_in(bounds) else { + return (canvas::event::Status::Ignored, None); + }; + let xl = MARGIN_L; + let xr = bounds.width - MARGIN_R; + + match event { + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + let dy = match delta { + mouse::ScrollDelta::Lines { y, .. } => y, + mouse::ScrollDelta::Pixels { y, .. } => y / 40.0, + }; + let factor = ZOOM_FACTOR.powf(dy); + let frac = screen_frac(pos.x, xl, xr); + let mut vr = state.freq.unwrap_or_else(|| self.auto_freq().unwrap_or(Vr::new(3.0, 5.3))); + vr.zoom_at(factor, frac); + state.freq = Some(vr); + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + let vr = state.freq.unwrap_or_else(|| self.auto_freq().unwrap_or(Vr::new(3.0, 5.3))); + state.drag = Some((pos.x, vr)); + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if let Some((start_x, start_vr)) = state.drag { + let dx_frac = (pos.x - start_x) / (xr - xl); + let mut vr = start_vr; + vr.pan_frac(dx_frac); + state.freq = Some(vr); + return (canvas::event::Status::Captured, None); + } + (canvas::event::Status::Ignored, None) + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + state.drag = None; + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + state.freq = None; + (canvas::event::Status::Captured, None) + } + _ => (canvas::event::Status::Ignored, None), + } + } + + fn draw( + &self, state: &BodeState, renderer: &Renderer, _theme: &Theme, + bounds: Rectangle, cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let (w, h) = (bounds.width, bounds.height); + + if self.points.is_empty() { + dt(&mut frame, Point::new(w / 2.0 - 25.0, h / 2.0), "No data", COL_DIM, 13.0); + return vec![frame.into_geometry()]; + } + + let split = (h * 0.55).floor(); + let xl = MARGIN_L; + let xr = w - MARGIN_R; + + let fv = state.freq.unwrap_or_else(|| self.auto_freq().unwrap()); + + // filter visible points for Y auto-scale + let vis: Vec<&EisPoint> = self.points.iter() + .filter(|p| { let lf = p.freq_hz.log10(); lf >= fv.lo && lf <= fv.hi }) + .collect(); + let all = if vis.is_empty() { self.points.iter().collect::>() } else { vis }; + + let (m_min, m_max) = all.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| { + (lo.min(p.mag_ohms), hi.max(p.mag_ohms)) + }); + let m_pad = (m_max - m_min).max(1.0) * 0.12; + let (m_lo, m_hi) = (m_min - m_pad, m_max + m_pad); + + let (p_min, p_max) = all.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| { + (lo.min(p.phase_deg), hi.max(p.phase_deg)) + }); + let p_pad = (p_max - p_min).max(0.1) * 0.25; + let (p_lo, p_hi) = (p_min - p_pad, p_max + p_pad); + + // freq grid + let d0 = fv.lo.floor() as i32; + let d1 = fv.hi.ceil() as i32; + for d in d0..=d1 { + let x = lerp(d as f32, fv.lo, fv.hi, xl, xr); + if x < xl || x > xr { continue; } + dl(&mut frame, Point::new(x, MARGIN_T), Point::new(x, h - MARGIN_B), COL_GRID, 1.0); + let hz = 10f32.powi(d); + let label = if hz >= 1000.0 { format!("{}k", hz as u32 / 1000) } else { format!("{}", hz as u32) }; + dt(&mut frame, Point::new(x - 8.0, split - 2.0), &label, COL_DIM, 9.0); + } + + dl(&mut frame, Point::new(xl, split), Point::new(xr, split), COL_AXIS, 1.0); + + // magnitude + let mag_top = MARGIN_T; + let mag_bot = split - 14.0; + let m_step = nice_step(m_hi - m_lo, 4); + if m_step > 0.0 { + let mut mg = (m_lo / m_step).ceil() * m_step; + while mg <= m_hi { + let y = lerp(mg, m_hi, m_lo, mag_top, mag_bot); + dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5); + dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.0}", mg), COL_MAG, 9.0); + mg += m_step; + } + } + dt(&mut frame, Point::new(2.0, mag_top - 2.0), "|Z|", COL_MAG, 10.0); + + let mag_pts: Vec = self.points.iter().map(|p| Point::new( + lerp(p.freq_hz.log10(), fv.lo, fv.hi, xl, xr), + lerp(p.mag_ohms, m_hi, m_lo, mag_top, mag_bot), + )).collect(); + draw_polyline(&mut frame, &mag_pts, COL_MAG, 2.0); + draw_dots(&mut frame, &mag_pts, COL_MAG, 2.5); + + // phase + let ph_top = split + 12.0; + let ph_bot = h - MARGIN_B; + let p_step = nice_step(p_hi - p_lo, 3); + if p_step > 0.0 { + let mut pg = (p_lo / p_step).ceil() * p_step; + while pg <= p_hi { + let y = lerp(pg, p_hi, p_lo, ph_top, ph_bot); + dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5); + dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.1}", pg), COL_PH, 9.0); + pg += p_step; + } + } + dt(&mut frame, Point::new(2.0, ph_top - 2.0), "Phase", COL_PH, 10.0); + + let ph_pts: Vec = self.points.iter().map(|p| Point::new( + lerp(p.freq_hz.log10(), fv.lo, fv.hi, xl, xr), + lerp(p.phase_deg, p_hi, p_lo, ph_top, ph_bot), + )).collect(); + draw_polyline(&mut frame, &ph_pts, COL_PH, 2.0); + draw_dots(&mut frame, &ph_pts, COL_PH, 2.5); + + // crosshair on hover + if let Some(pos) = cursor.position_in(bounds) { + if pos.x >= xl && pos.x <= xr && pos.y >= MARGIN_T && pos.y <= h - MARGIN_B { + let lf = lerp(pos.x, xl, xr, fv.lo, fv.hi); + let hz = 10f32.powf(lf); + let label = if hz >= 1000.0 { format!("{:.1}kHz", hz / 1000.0) } else { format!("{:.0}Hz", hz) }; + dl(&mut frame, Point::new(pos.x, MARGIN_T), Point::new(pos.x, h - MARGIN_B), + Color { a: 0.3, ..COL_AXIS }, 1.0); + dt(&mut frame, Point::new(pos.x + 4.0, MARGIN_T + 2.0), &label, COL_AXIS, 10.0); + } + } + + vec![frame.into_geometry()] + } +} + +/* ---- Nyquist state: zoom/pan on both axes ---- */ + +#[derive(Default)] +pub struct NyquistState { + view: Option<(Vr, Vr)>, + drag: Option<(Point, Vr, Vr)>, +} + +pub struct NyquistPlot<'a> { + pub points: &'a [EisPoint], +} + +impl NyquistPlot<'_> { + fn auto_view(&self) -> Option<(Vr, Vr)> { + if self.points.is_empty() { return None; } + let (re_min, re_max) = self.points.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| { + (lo.min(p.z_real), hi.max(p.z_real)) + }); + let (ni_min, ni_max) = self.points.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| { + (lo.min(-p.z_imag), hi.max(-p.z_imag)) + }); + let re_span = (re_max - re_min).max(1.0); + let ni_span = (ni_max - ni_min).max(1.0); + let span = re_span.max(ni_span) * 1.3; + let re_c = (re_min + re_max) / 2.0; + let ni_c = (ni_min + ni_max) / 2.0; + Some((Vr::new(re_c - span / 2.0, re_c + span / 2.0), + Vr::new(ni_c - span / 2.0, ni_c + span / 2.0))) + } +} + +impl<'a> canvas::Program for NyquistPlot<'a> { + type State = NyquistState; + + fn update( + &self, state: &mut NyquistState, event: Event, + bounds: Rectangle, cursor: mouse::Cursor, + ) -> (canvas::event::Status, Option) { + let Some(pos) = cursor.position_in(bounds) else { + return (canvas::event::Status::Ignored, None); + }; + let xl = MARGIN_L; + let xr = bounds.width - MARGIN_R; + let yt = MARGIN_T; + let yb = bounds.height - MARGIN_B; + + match event { + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + let dy = match delta { + mouse::ScrollDelta::Lines { y, .. } => y, + mouse::ScrollDelta::Pixels { y, .. } => y / 40.0, + }; + let factor = ZOOM_FACTOR.powf(dy); + let fx = screen_frac(pos.x, xl, xr); + let fy = screen_frac(pos.y, yt, yb); + let (mut xv, mut yv) = state.view.unwrap_or_else(|| self.auto_view().unwrap_or( + (Vr::new(0.0, 1.0), Vr::new(0.0, 1.0)) + )); + xv.zoom_at(factor, fx); + yv.zoom_at(factor, 1.0 - fy); + state.view = Some((xv, yv)); + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + let (xv, yv) = state.view.unwrap_or_else(|| self.auto_view().unwrap_or( + (Vr::new(0.0, 1.0), Vr::new(0.0, 1.0)) + )); + state.drag = Some((pos, xv, yv)); + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if let Some((start, sx, sy)) = state.drag { + let dx = (pos.x - start.x) / (xr - xl); + let dy = (pos.y - start.y) / (yb - yt); + let mut xv = sx; + let mut yv = sy; + xv.pan_frac(dx); + yv.pan_frac(-dy); + state.view = Some((xv, yv)); + return (canvas::event::Status::Captured, None); + } + (canvas::event::Status::Ignored, None) + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + state.drag = None; + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + state.view = None; + (canvas::event::Status::Captured, None) + } + _ => (canvas::event::Status::Ignored, None), + } + } + + fn draw( + &self, state: &NyquistState, renderer: &Renderer, _theme: &Theme, + bounds: Rectangle, cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let (w, h) = (bounds.width, bounds.height); + + if self.points.is_empty() { + dt(&mut frame, Point::new(w / 2.0 - 25.0, h / 2.0), "No data", COL_DIM, 13.0); + return vec![frame.into_geometry()]; + } + + let xl = MARGIN_L; + let xr = w - MARGIN_R; + let yt = MARGIN_T; + let yb = h - MARGIN_B; + + let (xv, yv) = state.view.unwrap_or_else(|| self.auto_view().unwrap()); + + // grid + let x_step = nice_step(xv.span(), 4); + if x_step > 0.0 { + let mut g = (xv.lo / x_step).ceil() * x_step; + while g <= xv.hi { + let x = lerp(g, xv.lo, xv.hi, xl, xr); + dl(&mut frame, Point::new(x, yt), Point::new(x, yb), COL_GRID, 0.5); + dt(&mut frame, Point::new(x - 10.0, yb + 3.0), &format!("{:.0}", g), COL_DIM, 9.0); + g += x_step; + } + } + let y_step = nice_step(yv.span(), 4); + if y_step > 0.0 { + let mut g = (yv.lo / y_step).ceil() * y_step; + while g <= yv.hi { + let y = lerp(g, yv.hi, yv.lo, yt, yb); + dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5); + dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.0}", g), COL_DIM, 9.0); + g += y_step; + } + } + + // zero line + let zy = lerp(0.0, yv.hi, yv.lo, yt, yb); + if zy > yt && zy < yb { + dl(&mut frame, Point::new(xl, zy), Point::new(xr, zy), COL_AXIS, 1.0); + } + + dt(&mut frame, Point::new(2.0, yt - 2.0), "-Z''", COL_NYQ, 10.0); + dt(&mut frame, Point::new((xl + xr) / 2.0 - 12.0, yb + 3.0), "Z'", COL_NYQ, 10.0); + + let pts: Vec = self.points.iter().map(|p| Point::new( + lerp(p.z_real, xv.lo, xv.hi, xl, xr), + lerp(-p.z_imag, yv.hi, yv.lo, yt, yb), + )).collect(); + draw_polyline(&mut frame, &pts, COL_NYQ, 2.0); + draw_dots(&mut frame, &pts, COL_NYQ, 3.0); + + // crosshair with values on hover + if let Some(pos) = cursor.position_in(bounds) { + if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb { + let re = lerp(pos.x, xl, xr, xv.lo, xv.hi); + let nim = lerp(pos.y, yt, yb, yv.hi, yv.lo); + dl(&mut frame, Point::new(pos.x, yt), Point::new(pos.x, yb), + Color { a: 0.3, ..COL_AXIS }, 1.0); + dl(&mut frame, Point::new(xl, pos.y), Point::new(xr, pos.y), + Color { a: 0.3, ..COL_AXIS }, 1.0); + dt(&mut frame, Point::new(pos.x + 4.0, pos.y - 14.0), + &format!("{:.1}, {:.1}", re, nim), COL_AXIS, 10.0); + } + } + + vec![frame.into_geometry()] + } +} diff --git a/main/ble.c b/main/ble.c index dfe2861..bb13d49 100644 --- a/main/ble.c +++ b/main/ble.c @@ -478,14 +478,17 @@ static int send_sysex(const uint8_t *sysex, uint16_t len) if (conn_hdl == BLE_HS_CONN_HANDLE_NONE || !midi_notify_en) return -1; - uint16_t pkt_len = 2 + len; + /* BLE MIDI SysEx: [header, ts, F0, payload..., ts, F7] */ + uint16_t pkt_len = len + 3; uint8_t pkt[80]; if (pkt_len > sizeof(pkt)) return -1; pkt[0] = 0x80; pkt[1] = 0x80; - memcpy(&pkt[2], sysex, len); + memcpy(&pkt[2], sysex, len - 1); + pkt[len + 1] = 0x80; + pkt[len + 2] = 0xF7; struct os_mbuf *om = ble_hs_mbuf_from_flat(pkt, pkt_len); if (!om) return -1; diff --git a/main/eis.c b/main/eis.c index a9adc61..551719f 100644 --- a/main/eis.c +++ b/main/eis.c @@ -2,6 +2,8 @@ #include #include #include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #ifndef M_PI #define M_PI 3.14159265358979323846 @@ -235,12 +237,11 @@ static void dft_measure(uint32_t mux_p, uint32_t mux_n, iImpCar_Type *out) AD5940_AFECtrlS(AFECTRL_WG | AFECTRL_ADCPWR, bTRUE); AD5940_Delay10us(25); + AD5940_ClrMCUIntFlag(); AD5940_AFECtrlS(AFECTRL_ADCCNV | AFECTRL_DFT, bTRUE); - uint32_t timeout = 10000000; - while (AD5940_INTCTestFlag(AFEINTC_1, AFEINTSRC_DFTRDY) == bFALSE) { - if (--timeout == 0) break; - } + while (!AD5940_GetMCUIntFlag()) + vTaskDelay(1); AD5940_AFECtrlS(AFECTRL_ADCCNV | AFECTRL_DFT | AFECTRL_WG | AFECTRL_ADCPWR, bFALSE); diff --git a/sdkconfig b/sdkconfig index 5a93e90..e5571d1 100644 --- a/sdkconfig +++ b/sdkconfig @@ -1754,7 +1754,7 @@ CONFIG_FATFS_DONT_TRUST_LAST_ALLOC=0 # # CONFIG_FREERTOS_SMP is not set # CONFIG_FREERTOS_UNICORE is not set -CONFIG_FREERTOS_HZ=100 +CONFIG_FREERTOS_HZ=1000 # CONFIG_FREERTOS_CHECK_STACKOVERFLOW_NONE is not set # CONFIG_FREERTOS_CHECK_STACKOVERFLOW_PTRVAL is not set CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY=y