712 lines
25 KiB
Rust
712 lines
25 KiB
Rust
#![no_std]
|
|
#![no_main]
|
|
|
|
mod fmt;
|
|
|
|
use core::mem;
|
|
use core::ops::{AddAssign, SubAssign};
|
|
use defmt::{error, Format};
|
|
#[cfg(not(feature = "defmt"))]
|
|
use panic_halt as _;
|
|
#[cfg(feature = "defmt")]
|
|
use {defmt_rtt as _, panic_probe as _};
|
|
#[cfg(feature = "defmt")]
|
|
use defmt::info;
|
|
|
|
use embassy_executor::Spawner;
|
|
use embassy_futures::join::{join3, join4};
|
|
use embassy_nrf::gpio::{AnyPin, Input, OutputDrive, Pull};
|
|
use embassy_nrf::interrupt::{InterruptExt, Priority};
|
|
use embassy_nrf::pwm::{DutyCycle, Prescaler, SimplePwm};
|
|
use embassy_nrf::{bind_interrupts, interrupt, saadc, Peri};
|
|
use embassy_nrf::peripherals::SAADC;
|
|
use embassy_nrf::saadc::{ChannelConfig, Gain, Reference, Saadc};
|
|
use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
|
|
use embassy_sync::mutex::Mutex;
|
|
use embassy_sync::signal::Signal;
|
|
use embassy_time::{with_timeout, Duration, Timer};
|
|
use futures::future::{select, Either};
|
|
use futures::pin_mut;
|
|
use nrf_softdevice::{raw, Softdevice};
|
|
use nrf_softdevice::ble::advertisement_builder::{Flag, LegacyAdvertisementBuilder, LegacyAdvertisementPayload, ServiceList, ServiceUuid16};
|
|
use nrf_softdevice::ble::{peripheral, gatt_server, Connection};
|
|
|
|
#[derive(Copy, Clone, Eq, PartialEq, Format)]
|
|
enum SysAction {
|
|
PresentenceOn,
|
|
PresentenceOff,
|
|
ConstantOn,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Eq, PartialEq, Format)]
|
|
enum TextLightType {
|
|
White,
|
|
ConstantColor,
|
|
Hue
|
|
}
|
|
|
|
#[derive(Copy, Clone, Eq, PartialEq, Format)]
|
|
enum HeartLightType {
|
|
ConstantColor,
|
|
Hue,
|
|
HeartBeat
|
|
}
|
|
|
|
#[derive(Copy, Clone, Eq, PartialEq, Format)]
|
|
pub enum TouchAction {
|
|
Tap,
|
|
DoubleTap,
|
|
TripleTap,
|
|
Hold,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Eq, PartialEq, Format)]
|
|
pub enum AnimationSignal<T> {
|
|
NextAnimation,
|
|
SetMode(T),
|
|
SetColor(u16),
|
|
}
|
|
|
|
static TOUCH_SIGNAL: Signal<ThreadModeRawMutex, TouchAction> = Signal::new();
|
|
static RADAR_SIGNAL: Signal<ThreadModeRawMutex, u8> = Signal::new();
|
|
static TEXT_ANIMATION_SIGNAL: Signal<ThreadModeRawMutex, AnimationSignal<TextLightType>> = Signal::new();
|
|
static HEART_ANIMATION_SIGNAL: Signal<ThreadModeRawMutex, AnimationSignal<HeartLightType>> = Signal::new();
|
|
static SYS_ACTION: Mutex<ThreadModeRawMutex, SysAction> = Mutex::new(SysAction::PresentenceOn);
|
|
static TEXT_ANIMATION: Mutex<ThreadModeRawMutex, TextLightType> = Mutex::new(TextLightType::Hue);
|
|
static HEART_ANIMATION: Mutex<ThreadModeRawMutex, HeartLightType> = Mutex::new(HeartLightType::HeartBeat);
|
|
|
|
#[nrf_softdevice::gatt_service(uuid = "9e7312e0-2354-11eb-9f10-fbc30a62cf38")]
|
|
struct AnimationService {
|
|
#[characteristic(uuid = "9e7312e0-2354-11eb-9f10-fbc30a63cf38", read, write, notify, indicate)]
|
|
text_mode: u16,
|
|
#[characteristic(uuid = "9e7312e0-2354-11eb-9f10-fbc30a64cf38", read, write, notify, indicate)]
|
|
text_color: u16,
|
|
#[characteristic(uuid = "9e7312e0-2354-11eb-9f10-fbc30a65cf38", read, write, notify, indicate)]
|
|
heart_mode: u16,
|
|
#[characteristic(uuid = "9e7312e0-2354-11eb-9f10-fbc30a66cf38", read, write, notify, indicate)]
|
|
heart_color: u16,
|
|
|
|
}
|
|
|
|
#[nrf_softdevice::gatt_service(uuid = "180f")]
|
|
struct BatteryService {
|
|
#[characteristic(uuid = "2a19", read, notify)]
|
|
battery_level: u8,
|
|
}
|
|
|
|
#[nrf_softdevice::gatt_server]
|
|
struct Server {
|
|
bas: BatteryService,
|
|
light: AnimationService,
|
|
}
|
|
|
|
bind_interrupts!(struct AdcIrqs {
|
|
SAADC => saadc::InterruptHandler;
|
|
});
|
|
|
|
#[embassy_executor::main]
|
|
async fn main(spawner: Spawner) {
|
|
let mut config = embassy_nrf::config::Config::default();
|
|
config.gpiote_interrupt_priority = Priority::P2;
|
|
config.time_interrupt_priority = Priority::P2;
|
|
let mut p = embassy_nrf::init(config);
|
|
|
|
let config = nrf_softdevice::Config {
|
|
clock: Some(raw::nrf_clock_lf_cfg_t {
|
|
source: raw::NRF_CLOCK_LF_SRC_RC as u8,
|
|
rc_ctiv: 16,
|
|
rc_temp_ctiv: 2,
|
|
accuracy: raw::NRF_CLOCK_LF_ACCURACY_500_PPM as u8,
|
|
}),
|
|
conn_gap: Some(raw::ble_gap_conn_cfg_t {
|
|
conn_count: 6,
|
|
event_length: 24,
|
|
}),
|
|
conn_gatt: Some(raw::ble_gatt_conn_cfg_t { att_mtu: 256 }),
|
|
gatts_attr_tab_size: Some(raw::ble_gatts_cfg_attr_tab_size_t {
|
|
attr_tab_size: raw::BLE_GATTS_ATTR_TAB_SIZE_DEFAULT,
|
|
}),
|
|
gap_role_count: Some(raw::ble_gap_cfg_role_count_t {
|
|
adv_set_count: 1,
|
|
periph_role_count: 3,
|
|
central_role_count: 3,
|
|
central_sec_count: 0,
|
|
_bitfield_1: raw::ble_gap_cfg_role_count_t::new_bitfield_1(0),
|
|
}),
|
|
gap_device_name: Some(raw::ble_gap_cfg_device_name_t {
|
|
p_value: b"Odesa!" as *const u8 as _,
|
|
current_len: 9,
|
|
max_len: 9,
|
|
write_perm: unsafe { mem::zeroed() },
|
|
_bitfield_1: raw::ble_gap_cfg_device_name_t::new_bitfield_1(raw::BLE_GATTS_VLOC_STACK as u8),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let sd = Softdevice::enable(&config);
|
|
let server = Server::new(sd).expect("failed to enable softdevice");
|
|
|
|
let mut channel_config = ChannelConfig::single_ended(p.P0_31.reborrow());
|
|
interrupt::SAADC.set_priority(Priority::P3);
|
|
channel_config.gain = Gain::GAIN1_6;
|
|
channel_config.reference = Reference::INTERNAL;
|
|
let saadc = Saadc::new(p.SAADC, AdcIrqs, saadc::Config::default(), [channel_config]);
|
|
saadc.calibrate().await;
|
|
|
|
let pwm_text = SimplePwm::new_3ch(p.PWM0, p.P1_00, p.P1_04, p.P1_06, &Default::default());
|
|
let pwm_heart = SimplePwm::new_3ch(p.PWM2, p.P0_17, p.P0_20, p.P0_22, &Default::default());
|
|
spawner.spawn(radar_task()).expect("failed to spawn radar task");
|
|
spawner.spawn(touch_button(p.P0_08.into())).expect("failed to spawn touch task");
|
|
spawner.spawn(actions_task()).expect("failed to spawn actions task");
|
|
spawner.spawn(softdevice_task(sd)).expect("failed to spawn softdevice task");
|
|
// spawner.spawn(battery_task(saadc)).expect("failed to spawn softdevice task");
|
|
|
|
join4(gatt_task(server, saadc, sd), moving_radar(p.P0_06.into()), heartbeat_task(pwm_heart), light_task(pwm_text)).await;
|
|
}
|
|
|
|
|
|
async fn gatt_task(server: Server, mut saadc: Saadc<'static, 1>, sd: &'static Softdevice) {
|
|
static ADV_DATA: LegacyAdvertisementPayload = LegacyAdvertisementBuilder::new()
|
|
.flags(&[Flag::GeneralDiscovery, Flag::LE_Only])
|
|
.services_16(ServiceList::Complete, &[ServiceUuid16::BATTERY])
|
|
.full_name("Odesa!")
|
|
.build();
|
|
|
|
static SCAN_DATA: LegacyAdvertisementPayload = LegacyAdvertisementBuilder::new()
|
|
.services_128(
|
|
ServiceList::Complete,
|
|
&[0x9e7312e0_2354_11eb_9f10_fbc30a62cf38_u128.to_le_bytes()],
|
|
)
|
|
.build();
|
|
|
|
loop {
|
|
let config = peripheral::Config::default();
|
|
let adv = peripheral::ConnectableAdvertisement::ScannableUndirected {
|
|
adv_data: &ADV_DATA,
|
|
scan_data: &SCAN_DATA,
|
|
};
|
|
let conn = peripheral::advertise_connectable(sd, adv, &config).await.expect("failed to advertise");
|
|
|
|
info!("advertising done!");
|
|
|
|
|
|
let battery_fut = battery_task(&mut saadc, &server, &conn);
|
|
let gatt_fut = gatt_server::run(&conn, &server, |e| match e {
|
|
ServerEvent::Bas(e) => match e {
|
|
BatteryServiceEvent::BatteryLevelCccdWrite { notifications } => {
|
|
info!("battery notifications: {}", notifications)
|
|
}
|
|
},
|
|
ServerEvent::Light(e) => match e {
|
|
AnimationServiceEvent::TextModeWrite(val) => {
|
|
let val = val % 3;
|
|
info!("wrote text mode: {}", val);
|
|
if let Err(e) = server.light.text_mode_notify(&conn, &(val)) {
|
|
info!("send notification error: {:?}", e);
|
|
}
|
|
let next = match val {
|
|
0 => TextLightType::Hue,
|
|
1 => TextLightType::ConstantColor,
|
|
2 => TextLightType::White,
|
|
_ => TextLightType::Hue,
|
|
};
|
|
TEXT_ANIMATION_SIGNAL.signal(AnimationSignal::SetMode(next));
|
|
}
|
|
AnimationServiceEvent::TextColorWrite(val) => {
|
|
let val = val % 1536;
|
|
info!("wrote text color: {}", val);
|
|
if let Err(e) = server.light.text_color_notify(&conn, &(val)) {
|
|
info!("send notification error: {:?}", e);
|
|
}
|
|
TEXT_ANIMATION_SIGNAL.signal(AnimationSignal::SetColor(val));
|
|
}
|
|
AnimationServiceEvent::HeartModeWrite(val) => {
|
|
let val = val % 3;
|
|
info!("wrote heart mode: {}", val);
|
|
if let Err(e) = server.light.heart_mode_notify(&conn, &(val)) {
|
|
info!("send notification error: {:?}", e);
|
|
}
|
|
let next = match val {
|
|
0 => HeartLightType::HeartBeat,
|
|
1 => HeartLightType::Hue,
|
|
2 => HeartLightType::ConstantColor,
|
|
_ => HeartLightType::HeartBeat,
|
|
};
|
|
HEART_ANIMATION_SIGNAL.signal(AnimationSignal::SetMode(next));
|
|
}
|
|
AnimationServiceEvent::HeartColorWrite(val) => {
|
|
let val = val % 1536;
|
|
info!("wrote heart color: {}", val);
|
|
if let Err(e) = server.light.heart_color_notify(&conn, &(val)) {
|
|
info!("send notification error: {:?}", e);
|
|
}
|
|
HEART_ANIMATION_SIGNAL.signal(AnimationSignal::SetColor(val));
|
|
}
|
|
AnimationServiceEvent::TextModeCccdWrite {
|
|
indications,
|
|
notifications,
|
|
} => {
|
|
info!("text mode indications: {}, notifications: {}", indications, notifications)
|
|
}
|
|
AnimationServiceEvent::TextColorCccdWrite {
|
|
indications,
|
|
notifications,
|
|
} => {
|
|
info!("text color indications: {}, notifications: {}", indications, notifications)
|
|
}
|
|
AnimationServiceEvent::HeartModeCccdWrite {
|
|
indications,
|
|
notifications,
|
|
} => {
|
|
info!("text mode indications: {}, notifications: {}", indications, notifications)
|
|
}
|
|
AnimationServiceEvent::HeartColorCccdWrite {
|
|
indications,
|
|
notifications,
|
|
} => {
|
|
info!("text color indications: {}, notifications: {}", indications, notifications)
|
|
}
|
|
},
|
|
});
|
|
|
|
pin_mut!(battery_fut);
|
|
pin_mut!(gatt_fut);
|
|
|
|
let _ = match select(battery_fut, gatt_fut).await {
|
|
Either::Left((_, _)) => {
|
|
info!("Battery encountered an error and stopped!")
|
|
}
|
|
Either::Right((e, _)) => {
|
|
info!("GATT run exited with error: {:?}", e);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
#[embassy_executor::task]
|
|
async fn softdevice_task(sd: &'static Softdevice) -> ! {
|
|
sd.run().await
|
|
}
|
|
|
|
#[embassy_executor::task]
|
|
async fn touch_button(pin: Peri<'static, AnyPin>) {
|
|
let mut button = Input::new(pin, Pull::Down);
|
|
|
|
loop {
|
|
info!("Wait for button");
|
|
button.wait_for_high().await;
|
|
let mut tap_count = 0;
|
|
let mut is_hold = false;
|
|
|
|
info!("Wait for low 1");
|
|
match with_timeout(Duration::from_millis(400), button.wait_for_low()).await {
|
|
Err(_) => {
|
|
info!("Look holding");
|
|
is_hold = true;
|
|
TOUCH_SIGNAL.signal(TouchAction::Hold);
|
|
button.wait_for_low().await;
|
|
}
|
|
Ok(_) => {
|
|
tap_count = 1;
|
|
|
|
info!("Wait for next tap ({})", tap_count);
|
|
loop {
|
|
match with_timeout(Duration::from_millis(300), button.wait_for_high()).await {
|
|
Ok(_) => {
|
|
info!("Wait for tap release ({})", tap_count);
|
|
button.wait_for_low().await;
|
|
tap_count += 1;
|
|
if tap_count >= 3 { break; }
|
|
}
|
|
Err(_) => {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Dispatch the action if it wasn't a hold
|
|
if !is_hold {
|
|
match tap_count {
|
|
1 => TOUCH_SIGNAL.signal(TouchAction::Tap),
|
|
2 => TOUCH_SIGNAL.signal(TouchAction::DoubleTap),
|
|
3 => TOUCH_SIGNAL.signal(TouchAction::TripleTap),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Debounce / Cool-down
|
|
Timer::after_millis(100).await;
|
|
}
|
|
}
|
|
|
|
async fn battery_task<'a>(saadc: &'a mut Saadc<'_, 1>, server: &'a Server, connection: &'a Connection) {
|
|
const REFERENCE: f32 = 3.6;
|
|
let mut measurements = [0i16; 4];
|
|
let mut index = 0;
|
|
|
|
loop {
|
|
let mut buf = [0; 1];
|
|
saadc.sample(&mut buf).await;
|
|
let voltage = ((buf[0] as f32 * REFERENCE) / 2.03f32) as i16;
|
|
measurements[index] = voltage;
|
|
index = (index + 1) % measurements.len();
|
|
if measurements.iter().any(|v| *v == 0) {
|
|
Timer::after_millis(1000).await;
|
|
} else {
|
|
let total = (measurements.iter().sum::<i16>() / measurements.len() as i16) as u16;
|
|
let percent = battery_mv_to_percent(total);
|
|
info!("Battery: {=i16}, {=u16}, {}%", voltage, total, percent);
|
|
if let Err(e) = server.bas.battery_level_notify(connection, &percent) {
|
|
error!("Error notifying battery level: {:?}", e);
|
|
}
|
|
Timer::after_millis(60000).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn battery_mv_to_percent(mv: u16) -> u8 {
|
|
match mv {
|
|
mv if mv >= 4150 => 100,
|
|
mv if mv <= 3000 => 0,
|
|
_ => {
|
|
let percent = (mv as u32 - 3000) * 100 / (4150 - 3000);
|
|
percent as u8
|
|
}
|
|
}
|
|
}
|
|
|
|
#[embassy_executor::task]
|
|
async fn actions_task() {
|
|
loop {
|
|
let signal = TOUCH_SIGNAL.wait().await;
|
|
info!("Action: {:?}", signal);
|
|
match signal {
|
|
// constant on vs presentence
|
|
TouchAction::Tap => {
|
|
let mut action = SYS_ACTION.lock().await;
|
|
match *action {
|
|
SysAction::PresentenceOn |
|
|
SysAction::PresentenceOff => {
|
|
*action = SysAction::ConstantOn;
|
|
info!("Constant light");
|
|
}
|
|
SysAction::ConstantOn => {
|
|
*action = SysAction::PresentenceOn;
|
|
info!("Light by presentence");
|
|
}
|
|
}
|
|
info!("Light mode {:?}", *action);
|
|
}
|
|
// change text animation
|
|
TouchAction::DoubleTap => {
|
|
TEXT_ANIMATION_SIGNAL.signal(AnimationSignal::NextAnimation);
|
|
}
|
|
// change anchor animation
|
|
TouchAction::TripleTap => {
|
|
HEART_ANIMATION_SIGNAL.signal(AnimationSignal::NextAnimation);
|
|
}
|
|
// enter pairing mode
|
|
TouchAction::Hold => {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[embassy_executor::task]
|
|
async fn radar_task() {
|
|
const MAX_TIME: usize = 480;
|
|
let mut time = MAX_TIME;
|
|
loop {
|
|
let action = {
|
|
let current = SYS_ACTION.lock().await;
|
|
*current
|
|
};
|
|
if action != SysAction::ConstantOn {
|
|
if let Err(_) = with_timeout(Duration::from_secs(1), RADAR_SIGNAL.wait()).await {
|
|
time = time.saturating_sub(1);
|
|
if time == 0 {
|
|
let mut action = SYS_ACTION.lock().await;
|
|
if *action == SysAction::PresentenceOn {
|
|
*action = SysAction::PresentenceOff;
|
|
info!("Off light due to no presentence");
|
|
}
|
|
}
|
|
} else {
|
|
if time == 0 {
|
|
let mut action = SYS_ACTION.lock().await;
|
|
if *action == SysAction::PresentenceOff {
|
|
*action = SysAction::PresentenceOn;
|
|
info!("On light due to presentence");
|
|
}
|
|
}
|
|
time = MAX_TIME;
|
|
}
|
|
} else {
|
|
time = MAX_TIME;
|
|
Timer::after_millis(500).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn moving_radar(pin: Peri<'static, AnyPin>) {
|
|
let mut radar = Input::new(pin, Pull::Down);
|
|
loop {
|
|
info!("Waiting for radar");
|
|
radar.wait_for_high().await;
|
|
RADAR_SIGNAL.signal(0);
|
|
while let Err(_) = with_timeout(Duration::from_secs(1), radar.wait_for_low()).await {
|
|
info!("Moving!!");
|
|
RADAR_SIGNAL.signal(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn light_task(mut pwm: SimplePwm<'static>) {
|
|
info!("Starting light task");
|
|
const MAX_DUTY: u16 = 32767;
|
|
|
|
pwm.set_prescaler(Prescaler::Div1);
|
|
pwm.set_max_duty(MAX_DUTY);
|
|
pwm.set_ch1_drive(OutputDrive::Standard);
|
|
pwm.set_ch2_drive(OutputDrive::Standard);
|
|
pwm.set_ch3_drive(OutputDrive::Standard);
|
|
pwm.set_all_duties([DutyCycle::normal(0), DutyCycle::normal(0), DutyCycle::normal(0), DutyCycle::normal(0)]);
|
|
|
|
let mut intensity = 0i32;
|
|
let mut hue: u16 = 0;
|
|
let mut animation_type = TextLightType::Hue;
|
|
|
|
loop {
|
|
let action = {
|
|
let guard = SYS_ACTION.lock().await;
|
|
guard.clone()
|
|
};
|
|
match action {
|
|
SysAction::PresentenceOn if intensity < 100 => {
|
|
intensity.add_assign(1);
|
|
info!("Increase intensity to {}", intensity);
|
|
},
|
|
SysAction::PresentenceOff if intensity > 0 => {
|
|
intensity.sub_assign(1);
|
|
info!("Decrease intensity to {}", intensity);
|
|
},
|
|
_ => {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if let Ok(val) = with_timeout(Duration::from_millis(10), TEXT_ANIMATION_SIGNAL.wait()).await {
|
|
match val {
|
|
AnimationSignal::NextAnimation => {
|
|
animation_type = match animation_type {
|
|
TextLightType::Hue => {
|
|
TextLightType::ConstantColor
|
|
}
|
|
TextLightType::ConstantColor => {
|
|
TextLightType::White
|
|
}
|
|
TextLightType::White => {
|
|
TextLightType::Hue
|
|
}
|
|
};
|
|
}
|
|
AnimationSignal::SetMode(val) => {
|
|
animation_type = val;
|
|
}
|
|
AnimationSignal::SetColor(val) => {
|
|
hue = val;
|
|
}
|
|
}
|
|
}
|
|
match animation_type {
|
|
TextLightType::Hue => {
|
|
let (r, g, b) = hue_to_rgb(hue, MAX_DUTY);
|
|
|
|
pwm.set_all_duties([
|
|
DutyCycle::normal(((r as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(((g as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(((b as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(0), // Ch4 unused
|
|
]);
|
|
|
|
hue = (hue + 1) % 1536;
|
|
}
|
|
TextLightType::White => {
|
|
let r = 12288; // Lowered significantly
|
|
let g = 16384;
|
|
let b = 24576;
|
|
|
|
// Set them once. No loop, no flicker.
|
|
pwm.set_all_duties([
|
|
DutyCycle::normal(((r) * intensity / 100) as u16),
|
|
DutyCycle::normal(((g) * intensity / 100) as u16),
|
|
DutyCycle::normal(((b) * intensity / 100) as u16),
|
|
DutyCycle::normal(0),
|
|
]);
|
|
}
|
|
TextLightType::ConstantColor => {
|
|
let (r, g, b) = hue_to_rgb(hue, MAX_DUTY);
|
|
|
|
pwm.set_all_duties([
|
|
DutyCycle::normal(((r as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(((g as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(((b as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(0), // Ch4 unused
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn hue_to_rgb(hue: u16, max: u16) -> (u16, u16, u16) {
|
|
let sector = (hue / 256) as u8;
|
|
let rising = (hue % 256) as u32 * max as u32 / 255;
|
|
let falling = max as u32 - rising;
|
|
|
|
let (r, g, b) = match sector {
|
|
0 => (max as u32, rising, 0), // Red to Yellow
|
|
1 => (falling, max as u32, 0), // Yellow to Green
|
|
2 => (0, max as u32, rising), // Green to Cyan
|
|
3 => (0, falling, max as u32), // Cyan to Blue
|
|
4 => (rising, 0, max as u32), // Blue to Magenta
|
|
_ => (max as u32, 0, falling), // Magenta to Red
|
|
};
|
|
|
|
(r as u16, g as u16, b as u16)
|
|
}
|
|
|
|
async fn heartbeat_task(mut pwm: SimplePwm<'static>) {
|
|
const MAX: u16 = 16384;
|
|
pwm.set_max_duty(MAX);
|
|
pwm.set_prescaler(Prescaler::Div1);
|
|
|
|
|
|
let mut intensity = 0i32;
|
|
let mut animation_type = HeartLightType::HeartBeat;
|
|
let mut hue: u16 = 0;
|
|
let mut heart_beat = 0;
|
|
|
|
loop {
|
|
let action = {
|
|
let guard = SYS_ACTION.lock().await;
|
|
guard.clone()
|
|
};
|
|
match action {
|
|
SysAction::PresentenceOn if intensity < 100 => {
|
|
intensity.add_assign(1);
|
|
info!("Increase intensity to {}", intensity);
|
|
},
|
|
SysAction::PresentenceOff if intensity > 0 => {
|
|
intensity.sub_assign(1);
|
|
info!("Decrease intensity to {}", intensity);
|
|
},
|
|
_ => {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if let Ok(val) = with_timeout(Duration::from_millis(10), HEART_ANIMATION_SIGNAL.wait()).await {
|
|
match val {
|
|
AnimationSignal::NextAnimation => {
|
|
animation_type = match animation_type {
|
|
HeartLightType::HeartBeat => {
|
|
HeartLightType::ConstantColor
|
|
}
|
|
HeartLightType::ConstantColor => {
|
|
HeartLightType::Hue
|
|
}
|
|
HeartLightType::Hue => {
|
|
HeartLightType::HeartBeat
|
|
}
|
|
};
|
|
}
|
|
AnimationSignal::SetMode(val) => {
|
|
animation_type = val;
|
|
}
|
|
AnimationSignal::SetColor(val) => {
|
|
hue = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
match animation_type {
|
|
HeartLightType::ConstantColor => {
|
|
let (r, g, b) = hue_to_rgb(hue, MAX);
|
|
|
|
pwm.set_all_duties([
|
|
DutyCycle::normal(((r as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(((g as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(((b as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(0), // Ch4 unused
|
|
]);
|
|
}
|
|
HeartLightType::Hue => {
|
|
let (r, g, b) = hue_to_rgb(hue, MAX);
|
|
|
|
pwm.set_all_duties([
|
|
DutyCycle::normal(((r as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(((g as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(((b as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(0), // Ch4 unused
|
|
]);
|
|
|
|
hue = (hue + 1) % 1536;
|
|
}
|
|
HeartLightType::HeartBeat => {
|
|
let beat = get_heartbeat(heart_beat);
|
|
|
|
pwm.set_all_duties([
|
|
DutyCycle::normal(((beat as i32) * intensity / 100) as u16),
|
|
DutyCycle::normal(0),
|
|
DutyCycle::normal(0),
|
|
DutyCycle::normal(0), // Ch4 unused
|
|
]);
|
|
|
|
heart_beat += 10;
|
|
if heart_beat >= 32768 {
|
|
heart_beat = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const BPM: u32 = 24;
|
|
const MIN_GLOW: u32 = 2048;
|
|
const MAX_VAL: u32 = 32768;
|
|
const PERIOD_MS: u32 = 60_000 / BPM;
|
|
|
|
/// Heartbeat logic optimized for MCU (no floats, precalculated constants)
|
|
pub fn get_heartbeat(sys_time_ms: u32) -> u16 {
|
|
let time_tick = (((sys_time_ms % PERIOD_MS) as u64 * MAX_VAL as u64) / PERIOD_MS as u64) as u32;
|
|
|
|
// 1. Calculate the pulse intensity (0..MAX_VAL)
|
|
let pulse = if time_tick > 2000 && time_tick < 7000 {
|
|
scale_pulse(time_tick, 2000, 4000, 7000, MAX_VAL)
|
|
} else if time_tick > 8500 && time_tick < 12500 {
|
|
scale_pulse(time_tick, 8500, 10000, 12500, MAX_VAL / 2)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
// 2. Square it for that organic "ease-in" feel
|
|
let eased_pulse = (pulse * pulse) >> 15;
|
|
|
|
// 3. Map the 0..MAX_VAL pulse into the MIN_GLOW..MAX_VAL range
|
|
// Formula: MIN + (eased_pulse * (MAX - MIN) / MAX)
|
|
let range = MAX_VAL - MIN_GLOW;
|
|
let final_val = MIN_GLOW + ((eased_pulse * range) >> 15);
|
|
|
|
final_val as u16
|
|
}
|
|
|
|
#[inline(always)]
|
|
fn scale_pulse(t: u32, start: u32, peak: u32, end: u32, max: u32) -> u32 {
|
|
if t <= peak {
|
|
((t - start) * max) / (peak - start)
|
|
} else {
|
|
((end - t) * max) / (end - peak)
|
|
}
|
|
} |