Compare commits
No commits in common. "852fa32c93199c91d27259516f2a8d6da7b8c96d" and "b14ac917d6a457e6d90ff4e0837feb66113bc366" have entirely different histories.
852fa32c93
...
b14ac917d6
21 changed files with 395 additions and 2005 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
[env]
|
|
||||||
DEFMT_LOG = "debug"
|
|
||||||
9
.envrc
9
.envrc
|
|
@ -1,9 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# the shebang is ignored, but nice for editors
|
|
||||||
|
|
||||||
if type -P lorri &>/dev/null; then
|
|
||||||
eval "$(lorri direnv)"
|
|
||||||
else
|
|
||||||
echo 'while direnv evaluated .envrc, could not find the command "lorri" [https://github.com/nix-community/lorri]'
|
|
||||||
use flake
|
|
||||||
fi
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
||||||
export
|
export
|
||||||
node_modules
|
node_modules
|
||||||
target
|
|
||||||
|
|
|
||||||
16
Cargo.toml
16
Cargo.toml
|
|
@ -1,16 +0,0 @@
|
||||||
cargo-features = ["per-package-target"]
|
|
||||||
|
|
||||||
[workspace]
|
|
||||||
members = [
|
|
||||||
"device-state",
|
|
||||||
"esp32",
|
|
||||||
"pico",
|
|
||||||
"simulator",
|
|
||||||
]
|
|
||||||
resolver = "2"
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
debug = true
|
|
||||||
debug-assertions = true
|
|
||||||
lto = "fat"
|
|
||||||
codegen-units = 1
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "device-state"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
owned_str = "0.1.2"
|
|
||||||
serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] }
|
|
||||||
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
|
|
||||||
ufmt = "0.2.0"
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
#![no_std]
|
|
||||||
|
|
||||||
extern crate alloc;
|
|
||||||
|
|
||||||
use alloc::string::String;
|
|
||||||
use core::str::FromStr;
|
|
||||||
|
|
||||||
use owned_str::OwnedStr;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use ufmt::uwrite;
|
|
||||||
|
|
||||||
const ARROW_RIGHT: char = char::from_u32(0b0111_1110).unwrap();
|
|
||||||
const ARROW_LEFT: char = char::from_u32(0b0111_1111).unwrap();
|
|
||||||
const DOT: char = char::from_u32(0b1010_0101).unwrap();
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum ViewState {
|
|
||||||
Loading,
|
|
||||||
Question,
|
|
||||||
Results,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Clone, Copy)]
|
|
||||||
pub enum QuestionType {
|
|
||||||
Choice,
|
|
||||||
Numeric { min: i32, max: i32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct QuestionData {
|
|
||||||
pub text: OwnedStr<256>,
|
|
||||||
pub q_type: QuestionType,
|
|
||||||
pub points: i32,
|
|
||||||
pub index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct QuestionDataNet<'a> {
|
|
||||||
pub text: &'a str,
|
|
||||||
pub q_type: QuestionType,
|
|
||||||
pub points: i32,
|
|
||||||
pub index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub enum ProxyOutput<'a> {
|
|
||||||
Question(QuestionDataNet<'a>),
|
|
||||||
Results,
|
|
||||||
Error(&'a str),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> From<QuestionDataNet<'a>> for QuestionData {
|
|
||||||
fn from(value: QuestionDataNet<'a>) -> Self {
|
|
||||||
QuestionData {
|
|
||||||
text: OwnedStr::from_str(value.text).unwrap(),
|
|
||||||
q_type: value.q_type,
|
|
||||||
points: value.points,
|
|
||||||
index: value.index,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct WheelData {
|
|
||||||
pub value: i32,
|
|
||||||
pub min: i32,
|
|
||||||
pub max: i32,
|
|
||||||
pub accumulated: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WheelData {
|
|
||||||
pub const fn empty() -> Self {
|
|
||||||
Self {
|
|
||||||
value: 0,
|
|
||||||
min: 0,
|
|
||||||
max: 0,
|
|
||||||
accumulated: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DeviceState {
|
|
||||||
view: ViewState,
|
|
||||||
question: Option<QuestionData>,
|
|
||||||
wheel: WheelData,
|
|
||||||
last_index: usize,
|
|
||||||
title_offset: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeviceState {
|
|
||||||
pub const fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
view: ViewState::Loading,
|
|
||||||
question: None,
|
|
||||||
wheel: WheelData::empty(),
|
|
||||||
last_index: 0,
|
|
||||||
title_offset: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.question = None;
|
|
||||||
self.wheel = WheelData::empty();
|
|
||||||
self.last_index = 0;
|
|
||||||
self.title_offset = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn view_state(&self) -> ViewState {
|
|
||||||
self.view
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wheel(&self) -> WheelData {
|
|
||||||
self.wheel
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wheel_mut(&mut self) -> &mut WheelData {
|
|
||||||
&mut self.wheel
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_proxy_output(&mut self, data: ProxyOutput<'_>) {
|
|
||||||
match data {
|
|
||||||
ProxyOutput::Question(data) => {
|
|
||||||
let data: QuestionData = data.into();
|
|
||||||
let mut future_wheel = WheelData::empty();
|
|
||||||
if let QuestionType::Numeric { min, max } = data.q_type {
|
|
||||||
future_wheel.max = max;
|
|
||||||
future_wheel.min = min;
|
|
||||||
future_wheel.value = (min + max) / 2;
|
|
||||||
}
|
|
||||||
self.question = Some(data);
|
|
||||||
self.view = ViewState::Question;
|
|
||||||
self.wheel = future_wheel;
|
|
||||||
}
|
|
||||||
ProxyOutput::Results => {
|
|
||||||
self.view = ViewState::Results;
|
|
||||||
}
|
|
||||||
ProxyOutput::Error(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn question(&self) -> Option<&QuestionData> {
|
|
||||||
self.question.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tick(&mut self) {
|
|
||||||
if self.view != ViewState::Question {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(question) = self.question.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
self.title_offset = self.title_offset.wrapping_add(1);
|
|
||||||
if question.index != self.last_index {
|
|
||||||
self.last_index = question.index;
|
|
||||||
self.title_offset = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_lines(&mut self) -> Option<(OwnedStr<16>, OwnedStr<16>)> {
|
|
||||||
if self.view != ViewState::Question {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let question = self.question.as_ref()?;
|
|
||||||
let title_line = if question.text.len() > 16 {
|
|
||||||
self.title_offset %= question.text.len() - 16;
|
|
||||||
OwnedStr::from_str(&question.text[self.title_offset..self.title_offset + 16]).unwrap()
|
|
||||||
} else {
|
|
||||||
OwnedStr::from_str(&question.text).unwrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
let number_str: OwnedStr<16> = match question.q_type {
|
|
||||||
QuestionType::Choice => {
|
|
||||||
let mut writer = OwnedStrWriter::new();
|
|
||||||
writer.push(DOT).unwrap();
|
|
||||||
writer.push(' ').unwrap();
|
|
||||||
uwrite!(writer, "{}", question.points).unwrap();
|
|
||||||
writer.into()
|
|
||||||
}
|
|
||||||
QuestionType::Numeric { min, max } => {
|
|
||||||
let mut writer = OwnedStrWriter::new();
|
|
||||||
if self.wheel.value > min {
|
|
||||||
writer.push(ARROW_LEFT).unwrap();
|
|
||||||
writer.push(' ').unwrap();
|
|
||||||
}
|
|
||||||
uwrite!(writer, "{}", self.wheel.value).unwrap();
|
|
||||||
if self.wheel.value < max {
|
|
||||||
writer.push(ARROW_RIGHT).unwrap();
|
|
||||||
writer.push(' ').unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.into()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let second_line = center_str::<16>(&number_str, 16).unwrap();
|
|
||||||
Some((title_line, second_line))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn response_value(&self, button: u8) -> i32 {
|
|
||||||
match self.question.as_ref() {
|
|
||||||
Some(q) => match q.q_type {
|
|
||||||
QuestionType::Numeric { .. } => self.wheel.value,
|
|
||||||
QuestionType::Choice => button as i32,
|
|
||||||
},
|
|
||||||
_ => button as i32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub enum WriteType<'a> {
|
|
||||||
QuizResponse(i32),
|
|
||||||
DeviceId(&'a str),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_proxy_output<'a>(input: &'a str) -> Result<ProxyOutput<'a>, serde_json::Error> {
|
|
||||||
serde_json::from_str::<ProxyOutput<'a>>(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn serialize_write(data: &WriteType<'_>) -> Result<String, serde_json::Error> {
|
|
||||||
serde_json::to_string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const WHEEL_TICKS: i32 = 4096;
|
|
||||||
|
|
||||||
pub fn wheel_delta(old_angle: i32, current_angle: i32, inverted: bool) -> i32 {
|
|
||||||
let mut diff = current_angle - old_angle;
|
|
||||||
if diff.abs() > WHEEL_TICKS / 2 {
|
|
||||||
diff = if diff > 0 {
|
|
||||||
diff - WHEEL_TICKS
|
|
||||||
} else {
|
|
||||||
diff + WHEEL_TICKS
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if inverted {
|
|
||||||
-diff
|
|
||||||
} else {
|
|
||||||
diff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_wheel_delta(
|
|
||||||
value: &mut i32,
|
|
||||||
min: i32,
|
|
||||||
max: i32,
|
|
||||||
accumulated: &mut i32,
|
|
||||||
diff: i32,
|
|
||||||
precision: i32,
|
|
||||||
) {
|
|
||||||
if max == min || precision <= 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
*accumulated += diff;
|
|
||||||
let step_count = *accumulated / precision;
|
|
||||||
if step_count == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
*accumulated -= step_count * precision;
|
|
||||||
*value += step_count;
|
|
||||||
if *value < min {
|
|
||||||
*value = min;
|
|
||||||
} else if *value > max {
|
|
||||||
*value = max;
|
|
||||||
}
|
|
||||||
if *value == min || *value == max {
|
|
||||||
*accumulated = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct OwnedStrWriter<const CAP: usize>(OwnedStr<CAP>);
|
|
||||||
|
|
||||||
impl<const CAP: usize> OwnedStrWriter<CAP> {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self(OwnedStr::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push(&mut self, c: char) -> Result<(), owned_str::Error> {
|
|
||||||
self.0.try_push(c).map(|_| ())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<const CAP: usize> From<OwnedStr<CAP>> for OwnedStrWriter<CAP> {
|
|
||||||
fn from(owned_str: OwnedStr<CAP>) -> Self {
|
|
||||||
Self(owned_str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<const CAP: usize> From<OwnedStrWriter<CAP>> for OwnedStr<CAP> {
|
|
||||||
fn from(writer: OwnedStrWriter<CAP>) -> Self {
|
|
||||||
writer.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<const CAP: usize> ufmt::uWrite for OwnedStrWriter<CAP> {
|
|
||||||
type Error = owned_str::Error;
|
|
||||||
fn write_str(&mut self, s: &str) -> Result<(), Self::Error> {
|
|
||||||
self.0.try_push_str(s).map(|_| ())
|
|
||||||
}
|
|
||||||
fn write_char(&mut self, c: char) -> Result<(), Self::Error> {
|
|
||||||
self.0.try_push(c).map(|_| ())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn center_str<const CAP: usize>(text: &str, width: usize) -> Result<OwnedStr<CAP>, owned_str::Error> {
|
|
||||||
let mut res = OwnedStr::new();
|
|
||||||
let len = text.len();
|
|
||||||
let padding = (width.saturating_sub(len) + 1) / 2;
|
|
||||||
for _ in 0..padding {
|
|
||||||
res.try_push(' ')?;
|
|
||||||
}
|
|
||||||
res.try_push_str(text)?;
|
|
||||||
Ok(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{apply_wheel_delta, wheel_delta};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wraps_forward_across_zero() {
|
|
||||||
assert_eq!(wheel_delta(4090, 5, false), 11);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wraps_backward_across_zero() {
|
|
||||||
assert_eq!(wheel_delta(5, 4090, false), -11);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn inverts_direction() {
|
|
||||||
assert_eq!(wheel_delta(10, 20, true), -10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accumulates_before_applying_selection() {
|
|
||||||
let mut value = 5;
|
|
||||||
let mut accumulated = 0;
|
|
||||||
|
|
||||||
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 10, 32);
|
|
||||||
assert_eq!(value, 5);
|
|
||||||
assert_eq!(accumulated, 10);
|
|
||||||
|
|
||||||
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 22, 32);
|
|
||||||
assert_eq!(value, 6);
|
|
||||||
assert_eq!(accumulated, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clamps_and_resets_at_bounds() {
|
|
||||||
let mut value = 10;
|
|
||||||
let mut accumulated = 0;
|
|
||||||
|
|
||||||
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 64, 32);
|
|
||||||
assert_eq!(value, 10);
|
|
||||||
assert_eq!(accumulated, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
[build]
|
|
||||||
target = "xtensa-esp32-none-elf"
|
|
||||||
|
|
||||||
[target.xtensa-esp32-none-elf]
|
[target.xtensa-esp32-none-elf]
|
||||||
runner = "espflash flash --monitor"
|
runner = "espflash flash --monitor"
|
||||||
rustflags = [
|
rustflags = [
|
||||||
"-C",
|
"-C", "link-arg=-Wl,-Tlinkall.x",
|
||||||
"link-arg=-Wl,-Tlinkall.x",
|
"-C", "link-arg=-nostartfiles",
|
||||||
"-C",
|
|
||||||
"link-arg=-nostartfiles",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[build]
|
||||||
|
target = "xtensa-esp32-none-elf"
|
||||||
|
|
||||||
[unstable]
|
[unstable]
|
||||||
build-std = ["core", "alloc", "compiler_builtins"]
|
build-std = ["core", "alloc", "compiler_builtins"]
|
||||||
|
|
||||||
|
|
|
||||||
1308
Cargo.lock → esp32/Cargo.lock
generated
1308
Cargo.lock → esp32/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +1,19 @@
|
||||||
cargo-features = ["per-package-target"]
|
|
||||||
[package]
|
[package]
|
||||||
name = "esp32"
|
name = "esp32"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
default-target = "xtensa-esp32-none-elf"
|
|
||||||
|
[profile.release]
|
||||||
|
debug = true
|
||||||
|
debug-assertions = true
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
[dependencies.device-state]
|
|
||||||
path = "../device-state"
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "xtensa")'.dependencies]
|
[target.'cfg(target_arch = "xtensa")'.dependencies]
|
||||||
|
serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] }
|
||||||
|
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
|
||||||
arrayvec = { version = "0.7.6", default-features = false }
|
arrayvec = { version = "0.7.6", default-features = false }
|
||||||
ag-lcd = { version = "0.3", features = ["ufmt"] }
|
ag-lcd = { version = "0.3", features = ["ufmt"] }
|
||||||
as5600 = "0.8.0"
|
as5600 = "0.8.0"
|
||||||
|
|
@ -32,15 +36,19 @@ embassy-time = { version = "0.5.1", features = [
|
||||||
esp-backtrace = { version = "0.19.0", features = ["esp32", "println"] }
|
esp-backtrace = { version = "0.19.0", features = ["esp32", "println"] }
|
||||||
static_cell = "2.1.1"
|
static_cell = "2.1.1"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
ufmt = "0.2.0"
|
||||||
|
owned_str = "0.1.2"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "arm")'.dependencies]
|
[target.'cfg(target_arch = "arm")'.dependencies]
|
||||||
embedded-hal-compat = "0.13.0"
|
embedded-hal-compat = "0.13.0"
|
||||||
|
ufmt = "0.2.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
ag-lcd={ version = "0.3", features = ["ufmt"]}
|
ag-lcd={ version = "0.3", features = ["ufmt"]}
|
||||||
as5600 = "0.8.0"
|
as5600 = "0.8.0"
|
||||||
embassy-futures = "0.1.2"
|
embassy-futures = "0.1.2"
|
||||||
embedded-io-async = "0.6.1"
|
embedded-io-async = "0.6.1"
|
||||||
embedded-io = "0.7.1"
|
embedded-io = "0.7.1"
|
||||||
|
owned_str = "0.1.2"
|
||||||
embassy-sync = "0.8.0"
|
embassy-sync = "0.8.0"
|
||||||
embassy-net = { version = "0.9.1",features = ["defmt", "icmp", "tcp", "udp", "raw", "dhcpv4", "medium-ethernet", "dns", "proto-ipv4", "proto-ipv6", "multicast"]}
|
embassy-net = { version = "0.9.1",features = ["defmt", "icmp", "tcp", "udp", "raw", "dhcpv4", "medium-ethernet", "dns", "proto-ipv4", "proto-ipv6", "multicast"]}
|
||||||
embassy-executor = { version = "0.10.0", features = [
|
embassy-executor = { version = "0.10.0", features = [
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ use esp_hal::peripherals::{GPIO21, GPIO22, I2C0};
|
||||||
use esp_hal::time::Rate;
|
use esp_hal::time::Rate;
|
||||||
use esp_println::println;
|
use esp_println::println;
|
||||||
|
|
||||||
use crate::STATE;
|
use crate::WHEEL_VALUE;
|
||||||
use device_state::{apply_wheel_delta, wheel_delta};
|
use esp32::{apply_wheel_delta, wheel_delta};
|
||||||
|
|
||||||
pub static INPUT: Channel<CriticalSectionRawMutex, u8, 64> = Channel::new();
|
pub static INPUT: Channel<CriticalSectionRawMutex, u8, 64> = Channel::new();
|
||||||
pub static ANGLE: Mutex<CriticalSectionRawMutex, u16> = Mutex::new(0);
|
pub static ANGLE: Mutex<CriticalSectionRawMutex, u16> = Mutex::new(0);
|
||||||
|
|
@ -39,8 +39,7 @@ pub async fn rotation_read_task(config: RotationConfig) {
|
||||||
*locked = angle;
|
*locked = angle;
|
||||||
drop(locked);
|
drop(locked);
|
||||||
let diff = wheel_delta(old, angle as i32, crate::WHEEL_INVERTED);
|
let diff = wheel_delta(old, angle as i32, crate::WHEEL_INVERTED);
|
||||||
let mut state = STATE.lock().await;
|
let mut wheel = WHEEL_VALUE.lock().await;
|
||||||
let wheel = state.wheel();
|
|
||||||
if wheel.max != wheel.min {
|
if wheel.max != wheel.min {
|
||||||
let min = wheel.min;
|
let min = wheel.min;
|
||||||
let max = wheel.max;
|
let max = wheel.max;
|
||||||
|
|
@ -48,7 +47,6 @@ pub async fn rotation_read_task(config: RotationConfig) {
|
||||||
let mut accumulated = wheel.accumulated;
|
let mut accumulated = wheel.accumulated;
|
||||||
let precision = crate::WHEEL_PRECISION;
|
let precision = crate::WHEEL_PRECISION;
|
||||||
apply_wheel_delta(&mut value, min, max, &mut accumulated, diff, precision);
|
apply_wheel_delta(&mut value, min, max, &mut accumulated, diff, precision);
|
||||||
let wheel = state.wheel_mut();
|
|
||||||
wheel.value = value;
|
wheel.value = value;
|
||||||
wheel.accumulated = accumulated;
|
wheel.accumulated = accumulated;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,5 @@
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
|
mod wheel;
|
||||||
|
|
||||||
|
pub use wheel::{apply_wheel_delta, wheel_delta};
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@
|
||||||
|
|
||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
|
|
||||||
use device_state::DeviceState;
|
use core::str::FromStr;
|
||||||
|
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
use embassy_futures::select::{Either, select};
|
use embassy_futures::select::{Either, select};
|
||||||
use embassy_net::tcp::{TcpReader, TcpWriter};
|
use embassy_net::tcp::{State, TcpReader, TcpWriter};
|
||||||
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 embassy_sync::signal::Signal;
|
use embassy_sync::signal::Signal;
|
||||||
use embassy_time::Timer;
|
use embassy_time::Timer;
|
||||||
|
use embedded_io::Write;
|
||||||
use esp_backtrace as _;
|
use esp_backtrace as _;
|
||||||
use esp_hal::{
|
use esp_hal::{
|
||||||
gpio::{Input, InputConfig as GpioInputConfig, Pin, Pull},
|
gpio::{Input, InputConfig as GpioInputConfig, Pin, Pull},
|
||||||
|
|
@ -18,14 +20,20 @@ use esp_hal::{
|
||||||
timer::timg::TimerGroup,
|
timer::timg::TimerGroup,
|
||||||
};
|
};
|
||||||
use esp_println::println;
|
use esp_println::println;
|
||||||
|
use owned_str::OwnedStr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ufmt::uwrite;
|
||||||
|
|
||||||
mod buffer;
|
mod buffer;
|
||||||
mod input;
|
mod input;
|
||||||
mod net;
|
mod net;
|
||||||
|
mod owned_str_writer;
|
||||||
mod screen;
|
mod screen;
|
||||||
|
|
||||||
pub use input::ANGLE;
|
pub use input::ANGLE;
|
||||||
|
|
||||||
|
use crate::screen::overwrite_lcd;
|
||||||
|
|
||||||
const WIFI_NETWORK: &str = "flamme";
|
const WIFI_NETWORK: &str = "flamme";
|
||||||
const WIFI_PASSWORD: &str = "12345678";
|
const WIFI_PASSWORD: &str = "12345678";
|
||||||
const TARGET_IP: &str = "84.238.32.253";
|
const TARGET_IP: &str = "84.238.32.253";
|
||||||
|
|
@ -34,6 +42,12 @@ const WHEEL_PRECISION: i32 = 32;
|
||||||
const WHEEL_INVERTED: bool = false;
|
const WHEEL_INVERTED: bool = false;
|
||||||
const DEVICE_ID: &str = "esp32-1";
|
const DEVICE_ID: &str = "esp32-1";
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
enum QuestionType {
|
||||||
|
Choice,
|
||||||
|
Numeric { min: i32, max: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum TcpDisconnect {
|
pub enum TcpDisconnect {
|
||||||
ReadError,
|
ReadError,
|
||||||
|
|
@ -42,11 +56,72 @@ pub enum TcpDisconnect {
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static STATE: Mutex<CriticalSectionRawMutex, DeviceState> = Mutex::new(DeviceState::new());
|
struct QuestionData {
|
||||||
|
text: OwnedStr<256>,
|
||||||
|
q_type: QuestionType,
|
||||||
|
points: i32,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct QuestionDataNet<'a> {
|
||||||
|
text: &'a str,
|
||||||
|
q_type: QuestionType,
|
||||||
|
points: i32,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
enum ProxyOutput<'a> {
|
||||||
|
Question(QuestionDataNet<'a>),
|
||||||
|
Results,
|
||||||
|
Error(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<QuestionDataNet<'a>> for QuestionData {
|
||||||
|
fn from(value: QuestionDataNet<'a>) -> Self {
|
||||||
|
QuestionData {
|
||||||
|
text: OwnedStr::from_str(value.text).unwrap(),
|
||||||
|
q_type: value.q_type,
|
||||||
|
points: value.points,
|
||||||
|
index: value.index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct WheelData {
|
||||||
|
value: i32,
|
||||||
|
min: i32,
|
||||||
|
max: i32,
|
||||||
|
accumulated: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum MainState {
|
||||||
|
Loading,
|
||||||
|
Question,
|
||||||
|
Results,
|
||||||
|
}
|
||||||
|
|
||||||
|
static MAIN_STATE: Mutex<CriticalSectionRawMutex, MainState> = Mutex::new(MainState::Loading);
|
||||||
|
static QUESTION: Mutex<CriticalSectionRawMutex, Option<QuestionData>> = Mutex::new(None);
|
||||||
|
static QUESTION_UPDATE: Signal<CriticalSectionRawMutex, ()> = Signal::new();
|
||||||
|
static WHEEL_VALUE: Mutex<CriticalSectionRawMutex, WheelData> = Mutex::new(WheelData {
|
||||||
|
value: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
accumulated: 0,
|
||||||
|
});
|
||||||
|
|
||||||
pub async fn reset_state() {
|
pub async fn reset_state() {
|
||||||
let mut state = STATE.lock().await;
|
*QUESTION.lock().await = None;
|
||||||
state.reset();
|
*WHEEL_VALUE.lock().await = WheelData {
|
||||||
|
value: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
accumulated: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[panic_handler]
|
#[panic_handler]
|
||||||
|
|
@ -80,23 +155,59 @@ pub async fn tcp_read_loop(
|
||||||
let Ok(str) = core::str::from_utf8(&buf[..len]) else {
|
let Ok(str) = core::str::from_utf8(&buf[..len]) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let mut question_data = None;
|
||||||
|
let mut future_wheel = WheelData {
|
||||||
|
value: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
accumulated: 0,
|
||||||
|
};
|
||||||
if let Some(last) = str.lines().last() {
|
if let Some(last) = str.lines().last() {
|
||||||
let Ok(data) = device_state::parse_proxy_output(last) else {
|
let Ok(data) = serde_json::from_str::<ProxyOutput>(last) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut state = STATE.lock().await;
|
match data {
|
||||||
state.apply_proxy_output(data);
|
ProxyOutput::Question(data) => {
|
||||||
|
let data: QuestionData = data.into();
|
||||||
|
match data.q_type {
|
||||||
|
QuestionType::Numeric { min, max } => {
|
||||||
|
future_wheel.max = max;
|
||||||
|
future_wheel.min = min;
|
||||||
|
future_wheel.value = (min + max) / 2;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
question_data = Some(data);
|
||||||
|
}
|
||||||
|
ProxyOutput::Results => {
|
||||||
|
*MAIN_STATE.lock().await = MainState::Results;
|
||||||
|
}
|
||||||
|
ProxyOutput::Error(e) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(question_data) = question_data {
|
||||||
|
*QUESTION.lock().await = Some(question_data);
|
||||||
|
*MAIN_STATE.lock().await = MainState::Question;
|
||||||
|
*WHEEL_VALUE.lock().await = future_wheel;
|
||||||
|
QUESTION_UPDATE.signal(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
enum WriteType<'a> {
|
||||||
|
QuizResponse(i32),
|
||||||
|
DeviceId(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn tcp_write_loop(
|
pub async fn tcp_write_loop(
|
||||||
mut write: TcpWriter<'_>,
|
mut write: TcpWriter<'_>,
|
||||||
cancel: &Signal<CriticalSectionRawMutex, ()>,
|
cancel: &Signal<CriticalSectionRawMutex, ()>,
|
||||||
) -> Result<(), TcpDisconnect> {
|
) -> Result<(), TcpDisconnect> {
|
||||||
if write
|
if write
|
||||||
.write(
|
.write(
|
||||||
device_state::serialize_write(&device_state::WriteType::DeviceId(DEVICE_ID))
|
serde_json::to_string(&WriteType::DeviceId(DEVICE_ID))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
)
|
)
|
||||||
|
|
@ -114,9 +225,19 @@ pub async fn tcp_write_loop(
|
||||||
Either::Second(()) => return Err(TcpDisconnect::Cancelled),
|
Either::Second(()) => return Err(TcpDisconnect::Cancelled),
|
||||||
};
|
};
|
||||||
println!("button={}", data);
|
println!("button={}", data);
|
||||||
let value = STATE.lock().await.response_value(data);
|
let value = {
|
||||||
let data = device_state::WriteType::QuizResponse(value);
|
let question = QUESTION.lock().await;
|
||||||
let buffer = device_state::serialize_write(&data).unwrap();
|
let wheel = *WHEEL_VALUE.lock().await;
|
||||||
|
match question.as_ref() {
|
||||||
|
Some(q) => match q.q_type {
|
||||||
|
QuestionType::Numeric { .. } => wheel.value,
|
||||||
|
QuestionType::Choice => data as _,
|
||||||
|
},
|
||||||
|
_ => data as _,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let data = WriteType::QuizResponse(value);
|
||||||
|
let buffer = serde_json::to_string(&data).unwrap();
|
||||||
println!("write: {}", &buffer);
|
println!("write: {}", &buffer);
|
||||||
if write.write(buffer.as_bytes()).await.is_err() {
|
if write.write(buffer.as_bytes()).await.is_err() {
|
||||||
cancel.signal(());
|
cancel.signal(());
|
||||||
|
|
@ -125,21 +246,72 @@ pub async fn tcp_write_loop(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ARROW_RIGHT: char = char::from_u32(0b0111_1110).unwrap();
|
||||||
|
const ARROW_LEFT: char = char::from_u32(0b0111_1111).unwrap();
|
||||||
|
const DOT: char = char::from_u32(0b1010_0101).unwrap();
|
||||||
|
|
||||||
#[embassy_executor::task]
|
#[embassy_executor::task]
|
||||||
pub async fn main_loop() {
|
pub async fn main_loop() {
|
||||||
|
let mut last_index = 0;
|
||||||
|
let mut title_offset = 0;
|
||||||
println!("Main loop started");
|
println!("Main loop started");
|
||||||
loop {
|
loop {
|
||||||
embassy_time::Timer::after_millis(50).await;
|
embassy_time::Timer::after_millis(50).await;
|
||||||
let mut state = STATE.lock().await;
|
let state = *MAIN_STATE.lock().await;
|
||||||
state.tick();
|
|
||||||
let lines = state.render_lines();
|
|
||||||
drop(state);
|
|
||||||
|
|
||||||
let Some((title_line, second_line)) = lines else {
|
match state {
|
||||||
|
MainState::Loading => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
MainState::Question => {}
|
||||||
|
MainState::Results => {
|
||||||
|
overwrite_lcd("Results", "").await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wheel = *WHEEL_VALUE.lock().await;
|
||||||
|
let question = QUESTION.lock().await;
|
||||||
|
let Some(question) = question.as_ref() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
title_offset += 1;
|
||||||
|
if question.index != last_index {
|
||||||
|
last_index = question.index;
|
||||||
|
title_offset = 0;
|
||||||
|
}
|
||||||
|
let title_line = if question.text.len() > 16 {
|
||||||
|
title_offset %= question.text.len() - 16;
|
||||||
|
&question.text[title_offset..title_offset + 16]
|
||||||
|
} else {
|
||||||
|
&question.text
|
||||||
|
};
|
||||||
|
let number_str: OwnedStr<16> = match question.q_type {
|
||||||
|
QuestionType::Choice => {
|
||||||
|
let mut writer = owned_str_writer::OwnedStrWriter::new();
|
||||||
|
writer.push(DOT).unwrap();
|
||||||
|
writer.push(' ').unwrap();
|
||||||
|
uwrite!(writer, "{}", question.points).unwrap();
|
||||||
|
writer.into()
|
||||||
|
}
|
||||||
|
QuestionType::Numeric { min, max } => {
|
||||||
|
let mut writer = owned_str_writer::OwnedStrWriter::new();
|
||||||
|
if wheel.value > min {
|
||||||
|
writer.push(ARROW_LEFT).unwrap();
|
||||||
|
writer.push(' ').unwrap();
|
||||||
|
}
|
||||||
|
uwrite!(writer, "{}", wheel.value).unwrap();
|
||||||
|
if wheel.value < max {
|
||||||
|
writer.push(ARROW_RIGHT).unwrap();
|
||||||
|
writer.push(' ').unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.into()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let second_line = owned_str_writer::center_str::<16>(&number_str, 16).unwrap();
|
||||||
println!("lcd: {} {}", title_line, second_line);
|
println!("lcd: {} {}", title_line, second_line);
|
||||||
screen::overwrite_lcd(&title_line, &second_line).await;
|
screen::overwrite_lcd(title_line, &second_line).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use esp_radio::wifi::sta::StationConfig;
|
||||||
use esp_radio::wifi::{Config, ControllerConfig, scan::ScanConfig};
|
use esp_radio::wifi::{Config, ControllerConfig, scan::ScanConfig};
|
||||||
|
|
||||||
use crate::screen::overwrite_lcd;
|
use crate::screen::overwrite_lcd;
|
||||||
use crate::{buffer::wait_for_config, tcp_read_loop, tcp_write_loop};
|
use crate::{TcpDisconnect, buffer::wait_for_config, tcp_read_loop, tcp_write_loop};
|
||||||
use crate::{WIFI_NETWORK, WIFI_PASSWORD};
|
use crate::{WIFI_NETWORK, WIFI_PASSWORD};
|
||||||
|
|
||||||
pub struct NetworkConfig<'a> {
|
pub struct NetworkConfig<'a> {
|
||||||
|
|
|
||||||
50
esp32/src/owned_str_writer.rs
Normal file
50
esp32/src/owned_str_writer.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use owned_str::{Error, OwnedStr};
|
||||||
|
use ufmt::uWrite;
|
||||||
|
|
||||||
|
pub struct OwnedStrWriter<const CAP: usize>(OwnedStr<CAP>);
|
||||||
|
|
||||||
|
impl<const CAP: usize> OwnedStrWriter<CAP> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(OwnedStr::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, c: char) -> Result<(), Error> {
|
||||||
|
self.0.try_push(c).map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const CAP: usize> From<OwnedStr<CAP>> for OwnedStrWriter<CAP> {
|
||||||
|
fn from(owned_str: OwnedStr<CAP>) -> Self {
|
||||||
|
Self(owned_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const CAP: usize> From<OwnedStrWriter<CAP>> for OwnedStr<CAP> {
|
||||||
|
fn from(writer: OwnedStrWriter<CAP>) -> Self {
|
||||||
|
writer.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const CAP: usize> uWrite for OwnedStrWriter<CAP> {
|
||||||
|
type Error = owned_str::Error;
|
||||||
|
fn write_str(&mut self, s: &str) -> Result<(), Self::Error> {
|
||||||
|
self.0.try_push_str(s).map(|_| ())
|
||||||
|
}
|
||||||
|
fn write_char(&mut self, c: char) -> Result<(), Self::Error> {
|
||||||
|
self.0.try_push(c).map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn center_str<const CAP: usize>(
|
||||||
|
str: &str,
|
||||||
|
width: usize,
|
||||||
|
) -> Result<OwnedStr<CAP>, owned_str::Error> {
|
||||||
|
let mut res = OwnedStr::new();
|
||||||
|
let len = str.len();
|
||||||
|
let padding = (width.saturating_sub(len) + 1) / 2;
|
||||||
|
for _ in 0..padding {
|
||||||
|
res.try_push(' ')?;
|
||||||
|
}
|
||||||
|
res.try_push_str(str)?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
48
esp32/src/wheel.rs
Normal file
48
esp32/src/wheel.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
pub const WHEEL_TICKS: i32 = 4096;
|
||||||
|
|
||||||
|
pub fn wheel_delta(old_angle: i32, current_angle: i32, inverted: bool) -> i32 {
|
||||||
|
let mut diff = current_angle - old_angle;
|
||||||
|
if diff.abs() > WHEEL_TICKS / 2 {
|
||||||
|
diff = if diff > 0 {
|
||||||
|
diff - WHEEL_TICKS
|
||||||
|
} else {
|
||||||
|
diff + WHEEL_TICKS
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if inverted {
|
||||||
|
-diff
|
||||||
|
} else {
|
||||||
|
diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_wheel_delta(
|
||||||
|
value: &mut i32,
|
||||||
|
min: i32,
|
||||||
|
max: i32,
|
||||||
|
accumulated: &mut i32,
|
||||||
|
diff: i32,
|
||||||
|
precision: i32,
|
||||||
|
) {
|
||||||
|
if max == min || precision <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*accumulated += diff;
|
||||||
|
let step_count = *accumulated / precision;
|
||||||
|
if step_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*accumulated -= step_count * precision;
|
||||||
|
*value += step_count;
|
||||||
|
if *value < min {
|
||||||
|
*value = min;
|
||||||
|
} else if *value > max {
|
||||||
|
*value = max;
|
||||||
|
}
|
||||||
|
if *value == min || *value == max {
|
||||||
|
*accumulated = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
esp32/test.sh
Normal file
1
esp32/test.sh
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
rustc --test wheel-tests.rs -o wheel-tests && ./wheel-tests
|
||||||
46
esp32/wheel-tests.rs
Normal file
46
esp32/wheel-tests.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
mod wheel {
|
||||||
|
include!("src/wheel.rs");
|
||||||
|
}
|
||||||
|
|
||||||
|
use wheel::{apply_wheel_delta, wheel_delta};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wraps_forward_across_zero() {
|
||||||
|
assert_eq!(wheel_delta(4090, 5, false), 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wraps_backward_across_zero() {
|
||||||
|
assert_eq!(wheel_delta(5, 4090, false), -11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inverts_direction() {
|
||||||
|
assert_eq!(wheel_delta(10, 20, true), -10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accumulates_before_applying_selection() {
|
||||||
|
let mut value = 5;
|
||||||
|
let mut accumulated = 0;
|
||||||
|
|
||||||
|
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 10, 32);
|
||||||
|
assert_eq!(value, 5);
|
||||||
|
assert_eq!(accumulated, 10);
|
||||||
|
|
||||||
|
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 22, 32);
|
||||||
|
assert_eq!(value, 6);
|
||||||
|
assert_eq!(accumulated, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamps_and_resets_at_bounds() {
|
||||||
|
let mut value = 10;
|
||||||
|
let mut accumulated = 0;
|
||||||
|
|
||||||
|
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 64, 32);
|
||||||
|
assert_eq!(value, 10);
|
||||||
|
assert_eq!(accumulated, 0);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
cargo-features = ["per-package-target"]
|
|
||||||
[package]
|
|
||||||
name = "simulator"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
default-target = "x86_64-unknown-linux-gnu"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
clap = { version = "4.5.45", features = ["derive"] }
|
|
||||||
crossterm = "0.28.1"
|
|
||||||
device-state = { path = "../device-state" }
|
|
||||||
tokio = { version = "1.47.0", features = ["macros", "rt-multi-thread", "net", "sync", "time", "io-util", "signal"] }
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
use std::io::{self, Write as _};
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
|
||||||
use crossterm::cursor::Show;
|
|
||||||
use crossterm::queue;
|
|
||||||
use crossterm::event;
|
|
||||||
use device_state::{DeviceState, WriteType, apply_wheel_delta};
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpStream;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio::time::{Duration, interval};
|
|
||||||
|
|
||||||
const DEVICE_ID: &str = "esp32-1";
|
|
||||||
const DEFAULT_HOST: &str = "84.238.32.253";
|
|
||||||
const DEFAULT_PORT: u16 = 7070;
|
|
||||||
const WHEEL_PRECISION: i32 = 32;
|
|
||||||
const WHEEL_STEP: i32 = 1;
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[command(author, version, about)]
|
|
||||||
struct Args {
|
|
||||||
#[arg(long, default_value = DEFAULT_HOST)]
|
|
||||||
host: String,
|
|
||||||
#[arg(long, default_value_t = DEFAULT_PORT)]
|
|
||||||
port: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum InputEvent {
|
|
||||||
Button(u8),
|
|
||||||
WheelDelta(i32),
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TerminalGuard;
|
|
||||||
|
|
||||||
impl TerminalGuard {
|
|
||||||
fn new() -> Self {
|
|
||||||
let _ = enable_raw_mode();
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TerminalGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = disable_raw_mode();
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
let _ = queue!(stdout, Show);
|
|
||||||
let _ = stdout.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> io::Result<()> {
|
|
||||||
let args = Args::parse();
|
|
||||||
let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse().unwrap();
|
|
||||||
|
|
||||||
let _terminal = TerminalGuard::new();
|
|
||||||
let state = Arc::new(Mutex::new(DeviceState::new()));
|
|
||||||
let (input_tx, mut input_rx) = mpsc::channel::<InputEvent>(64);
|
|
||||||
|
|
||||||
spawn_input_thread(input_tx.clone());
|
|
||||||
tokio::spawn(ui_task(state.clone()));
|
|
||||||
|
|
||||||
let mut buf = [0u8; 1024];
|
|
||||||
let sigint = tokio::signal::ctrl_c();
|
|
||||||
tokio::pin!(sigint);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let stream = loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = &mut sigint => {
|
|
||||||
log_error("received SIGINT");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
maybe_event = input_rx.recv() => {
|
|
||||||
if matches!(maybe_event, Some(InputEvent::Quit) | None) {
|
|
||||||
log_error("quitting");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connect = TcpStream::connect(addr) => {
|
|
||||||
match connect {
|
|
||||||
Ok(stream) => break stream,
|
|
||||||
Err(err) => {
|
|
||||||
log_error(&format!("connect error: {err}"));
|
|
||||||
tokio::select! {
|
|
||||||
_ = &mut sigint => {
|
|
||||||
log_error("received SIGINT");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
maybe_event = input_rx.recv() => {
|
|
||||||
if matches!(maybe_event, Some(InputEvent::Quit) | None) {
|
|
||||||
log_error("quitting");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let (mut read, mut write) = stream.into_split();
|
|
||||||
|
|
||||||
let device_id = device_state::serialize_write(&WriteType::DeviceId(DEVICE_ID)).unwrap();
|
|
||||||
if write.write_all(device_id.as_bytes()).await.is_err() {
|
|
||||||
log_error("failed to send device id");
|
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = &mut sigint => {
|
|
||||||
log_error("received SIGINT");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
maybe_event = input_rx.recv() => {
|
|
||||||
match maybe_event {
|
|
||||||
Some(InputEvent::Quit) | None => {
|
|
||||||
log_error("quitting");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Some(InputEvent::Button(id)) => {
|
|
||||||
let value = state.lock().unwrap().response_value(id);
|
|
||||||
let data = WriteType::QuizResponse(value);
|
|
||||||
let buffer = device_state::serialize_write(&data).unwrap();
|
|
||||||
if write.write_all(buffer.as_bytes()).await.is_err() {
|
|
||||||
log_error("write error while sending response");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(InputEvent::WheelDelta(diff)) => {
|
|
||||||
let mut state = state.lock().unwrap();
|
|
||||||
let wheel = state.wheel();
|
|
||||||
if wheel.max != wheel.min {
|
|
||||||
let mut value = wheel.value;
|
|
||||||
let mut accumulated = wheel.accumulated;
|
|
||||||
apply_wheel_delta(
|
|
||||||
&mut value,
|
|
||||||
wheel.min,
|
|
||||||
wheel.max,
|
|
||||||
&mut accumulated,
|
|
||||||
diff,
|
|
||||||
WHEEL_PRECISION,
|
|
||||||
);
|
|
||||||
let wheel = state.wheel_mut();
|
|
||||||
wheel.value = value;
|
|
||||||
wheel.accumulated = accumulated;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
read_res = read.read(&mut buf) => {
|
|
||||||
let len = match read_res {
|
|
||||||
Ok(0) => {
|
|
||||||
log_error("server closed connection");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(len) => len,
|
|
||||||
Err(err) => {
|
|
||||||
log_error(&format!("read error: {err}"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Ok(text) = core::str::from_utf8(&buf[..len]) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(last) = text.lines().last() {
|
|
||||||
let Ok(data) = device_state::parse_proxy_output(last) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut state = state.lock().unwrap();
|
|
||||||
state.apply_proxy_output(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log_error("reconnecting in 500ms");
|
|
||||||
tokio::select! {
|
|
||||||
_ = &mut sigint => {
|
|
||||||
log_error("received SIGINT");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
maybe_event = input_rx.recv() => {
|
|
||||||
if matches!(maybe_event, Some(InputEvent::Quit) | None) {
|
|
||||||
log_error("quitting");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = tokio::time::sleep(Duration::from_millis(500)) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_input_thread(tx: mpsc::Sender<InputEvent>) {
|
|
||||||
let _ = thread::Builder::new()
|
|
||||||
.name("sim-input".to_string())
|
|
||||||
.spawn(move || input_thread(tx));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn input_thread(tx: mpsc::Sender<InputEvent>) {
|
|
||||||
loop {
|
|
||||||
if let Ok(Event::Key(key)) = event::read() {
|
|
||||||
if key.kind == KeyEventKind::Release {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let event = match key.code {
|
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
Some(InputEvent::Quit)
|
|
||||||
}
|
|
||||||
KeyCode::Char('1') => Some(InputEvent::Button(1)),
|
|
||||||
KeyCode::Char('2') => Some(InputEvent::Button(2)),
|
|
||||||
KeyCode::Char('3') => Some(InputEvent::Button(3)),
|
|
||||||
KeyCode::Char('4') => Some(InputEvent::Button(4)),
|
|
||||||
KeyCode::Left => Some(InputEvent::WheelDelta(-WHEEL_STEP)),
|
|
||||||
KeyCode::Right => Some(InputEvent::WheelDelta(WHEEL_STEP)),
|
|
||||||
KeyCode::Char('a') => Some(InputEvent::WheelDelta(-WHEEL_STEP)),
|
|
||||||
KeyCode::Char('d') => Some(InputEvent::WheelDelta(WHEEL_STEP)),
|
|
||||||
KeyCode::Char('q') => Some(InputEvent::Quit),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(event) = event {
|
|
||||||
let quit = matches!(event, InputEvent::Quit);
|
|
||||||
if tx.blocking_send(event).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if quit {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ui_task(state: Arc<Mutex<DeviceState>>) {
|
|
||||||
let mut last = (String::new(), String::new());
|
|
||||||
let mut ticker = interval(Duration::from_millis(50));
|
|
||||||
loop {
|
|
||||||
ticker.tick().await;
|
|
||||||
let (line1, line2) = {
|
|
||||||
let mut state = state.lock().unwrap();
|
|
||||||
state.tick();
|
|
||||||
match state.render_lines() {
|
|
||||||
Some((line1, line2)) => (line1, line2),
|
|
||||||
None => continue,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let rendered = (pad_16(line1.as_str()), pad_16(line2.as_str()));
|
|
||||||
if rendered == last {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
last = rendered.clone();
|
|
||||||
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
let _ = write!(stdout, "[lcd] {}\r\n", rendered.0);
|
|
||||||
let _ = write!(stdout, "[lcd] {}\r\n", rendered.1);
|
|
||||||
let _ = stdout.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pad_16(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(16);
|
|
||||||
for ch in s.chars().take(16) {
|
|
||||||
out.push(ch);
|
|
||||||
}
|
|
||||||
while out.len() < 16 {
|
|
||||||
out.push(' ');
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_error(msg: &str) {
|
|
||||||
let mut stderr = io::stderr();
|
|
||||||
let _ = write!(stderr, "[simulator] {msg}\r\n");
|
|
||||||
let _ = stderr.flush();
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue