Add build features for multiple device types

The bin crate now has two features, one for each supported device.

* The IOCTL change from #142 is compiled in conditionally.
* Tp-link display is supported & tested for HW rev 3 and HW rev 5.

The release tarballs now contain two rayhunter-daemon binaries, for
orbic and tplink. An installer for tplink is not yet included.

Co-authored-by: m0veax <m0veax@chaospott.de>
This commit is contained in:
Markus Unterwaditzer
2025-03-31 20:41:29 +02:00
parent 7b897c335d
commit 499b86aca6
18 changed files with 709 additions and 214 deletions

View File

@@ -0,0 +1,196 @@
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
use std::time::Duration;
use std::io::Cursor;
use crate::config;
use crate::display::DisplayState;
use log::{error, info};
use tokio::sync::oneshot;
use tokio::sync::mpsc::Receiver;
use tokio_util::task::TaskTracker;
use tokio::sync::oneshot::error::TryRecvError;
use std::thread::sleep;
use include_dir::{include_dir, Dir};
#[derive(Copy, Clone)]
pub struct Dimensions {
pub height: u32,
pub width: u32,
}
#[allow(dead_code)]
#[derive(Copy, Clone)]
pub enum Color {
Red ,
Green ,
Blue ,
White ,
Black ,
Cyan ,
Yellow ,
Pink,
}
impl Color {
fn rgb(self) -> (u8, u8, u8) {
match self {
Color::Red => (0xff, 0, 0),
Color::Green => (0, 0xff, 0),
Color::Blue => (0, 0, 0xff),
Color::White => (0xff, 0xff, 0xff),
Color::Black => (0, 0, 0),
Color::Cyan => (0, 0xff, 0xff),
Color::Yellow => (0xff, 0xff, 0),
Color::Pink => (0xfe, 0x24, 0xff),
}
}
}
impl From<DisplayState> for Color{
fn from(state: DisplayState) -> Self {
match state {
DisplayState::Paused => Color::White,
DisplayState::Recording => Color::Green,
DisplayState::RecordingCBM => Color::Blue,
DisplayState::WarningDetected => Color::Red,
}
}
}
pub trait GenericFramebuffer: Send + 'static {
fn dimensions(&self) -> Dimensions;
fn write_buffer(
&mut self,
buffer: &[(u8, u8, u8)], // rgb, row-wise, left-to-right, top-to-bottom
);
fn write_dynamic_image(&mut self, img: DynamicImage) {
let dimensions = self.dimensions();
let mut width = img.width();
let mut height = img.height();
let resized_img: DynamicImage;
if height > dimensions.height ||
width > dimensions.width {
resized_img = img.resize( dimensions.width, dimensions.height, FilterType::CatmullRom);
width = dimensions.width.min(resized_img.width());
height = dimensions.height.min(resized_img.height());
} else {
resized_img = img;
}
let img_rgba8 = resized_img.as_rgba8().unwrap();
let mut buf = Vec::new();
for y in 0..height {
for x in 0..width {
let px = img_rgba8.get_pixel(x, y);
buf.push((px[0], px[1], px[2]));
}
}
self.write_buffer(
&buf,
);
}
fn draw_gif(&mut self, img_buffer: &[u8]) {
// this is dumb and i'm sure there's a better way to loop this
let cursor = Cursor::new(img_buffer);
let decoder = GifDecoder::new(cursor).unwrap();
for maybe_frame in decoder.into_frames() {
let frame = maybe_frame.unwrap();
let (numerator, _) = frame.delay().numer_denom_ms();
let img = DynamicImage::from(frame.into_buffer());
self.write_dynamic_image(img);
std::thread::sleep(Duration::from_millis(numerator as u64));
}
}
fn draw_img(&mut self, img_buffer: &[u8]) {
let img = image::load_from_memory(img_buffer).unwrap();
self.write_dynamic_image(img);
}
fn draw_line(&mut self, color: Color, height: u32) {
let width = self.dimensions().width;
let px_num = height * width;
let mut buffer = Vec::new();
for _ in 0..px_num {
buffer.push(color.rgb());
}
self.write_buffer(&buffer);
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut fb: impl GenericFramebuffer,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: Receiver<DisplayState>
) {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/images/");
let mut display_color: Color;
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
if config.colorblind_mode {
display_color = Color::Blue;
} else {
display_color = Color::Green;
}
task_tracker.spawn_blocking(move || {
// this feels wrong, is there a more rusty way to do this?
let mut img: Option<&[u8]> = None;
if display_level == 2 {
img = Some(IMAGE_DIR.get_file("orca.gif").expect("failed to read orca.gif").contents());
} else if display_level == 3 {
img = Some(IMAGE_DIR.get_file("eff.png").expect("failed to read eff.png").contents());
}
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
},
Err(TryRecvError::Empty) => {},
Err(e) => panic!("error receiving shutdown message: {e}")
}
match ui_update_rx.try_recv() {
Ok(state) => {
display_color = state.into();
},
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {},
Err(e) => error!("error receiving framebuffer update message: {e}")
}
match display_level {
2 => {
fb.draw_gif(img.unwrap());
},
3 => {
fb.draw_img(img.unwrap())
},
128 => {
fb.draw_line(Color::Cyan, 128);
fb.draw_line(Color::Pink, 102);
fb.draw_line(Color::White, 76);
fb.draw_line(Color::Pink, 50);
fb.draw_line(Color::Cyan, 25);
},
_ => { // this branch id for ui_level 1, which is also the default if an
// unknown value is used
fb.draw_line(display_color, 2);
},
};
sleep(Duration::from_millis(1000));
}
});
}

29
bin/src/display/mod.rs Normal file
View File

@@ -0,0 +1,29 @@
mod generic_framebuffer;
#[cfg(feature = "tplink")]
mod tplink;
#[cfg(feature = "tplink")]
mod tplink_framebuffer;
#[cfg(feature = "tplink")]
mod tplink_onebit;
#[cfg(feature = "tplink")]
pub use tplink::update_ui;
#[cfg(feature = "orbic")]
mod orbic;
#[cfg(feature = "orbic")]
pub use orbic::update_ui;
pub enum DisplayState {
Recording,
Paused,
WarningDetected,
RecordingCBM,
}
#[cfg(all(feature = "orbic", feature = "tplink"))]
compile_error!("cannot compile for many devices at once");
#[cfg(not(any(feature = "orbic", feature = "tplink")))]
compile_error!("cannot compile for no device at all");

52
bin/src/display/orbic.rs Normal file
View File

@@ -0,0 +1,52 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, GenericFramebuffer, Dimensions};
use tokio::sync::oneshot;
use tokio::sync::mpsc::Receiver;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
Dimensions {
height: 128,
width: 128,
}
}
fn write_buffer(
&mut self,
buffer: &[(u8, u8, u8)],
) {
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes());
}
std::fs::write(FB_PATH, &raw_buffer).unwrap();
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
ui_shutdown_rx,
ui_update_rx,
)
}

39
bin/src/display/tplink.rs Normal file
View File

@@ -0,0 +1,39 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::display::{DisplayState, tplink_onebit, tplink_framebuffer};
use std::fs;
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
info!("detected one-bit display");
tplink_onebit::update_ui(
task_tracker,
config,
ui_shutdown_rx,
ui_update_rx
)
} else {
info!("fallback to framebuffer");
tplink_framebuffer::update_ui(
task_tracker,
config,
ui_shutdown_rx,
ui_update_rx
)
}
}

View File

@@ -0,0 +1,93 @@
use std::fs::File;
use std::io::Write;
use std::os::fd::AsRawFd;
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, GenericFramebuffer, Dimensions};
use tokio::sync::oneshot;
use tokio::sync::mpsc::Receiver;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
struct Framebuffer;
#[repr(C)]
struct fb_fillrect {
dx: u32,
dy: u32,
width: u32,
height: u32,
color: u32,
rop: u32,
}
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
Dimensions {
height: 128,
width: 128,
}
}
fn write_buffer(
&mut self,
buffer: &[(u8, u8, u8)],
) {
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
let dimensions = self.dimensions();
let width = dimensions.width;
let height = buffer.len() as u32 / width;
let mut f = File::options().write(true).open(FB_PATH).unwrap();
let mut arg = fb_fillrect {
dx: 0,
dy: 0,
width,
height,
color: 0xffff, // not sure what this is
rop: 0,
};
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
// note: big-endian!
raw_buffer.extend(rgb565.to_be_bytes());
}
f.write_all(&raw_buffer).unwrap();
unsafe {
let res = libc::ioctl(
f.as_raw_fd(),
0x4619, // FBIORECT_DISPLAY
&mut arg as *mut _,
std::mem::size_of::<fb_fillrect>(),
);
if res < 0 {
panic!("failed to send FBIORECT_DISPLAY ioctl, {}", res);
}
}
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
ui_shutdown_rx,
ui_update_rx,
)
}

View File

@@ -0,0 +1,188 @@
/// Display module for the TP-Link M7350 oled one-bit display.
///
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
use crate::config;
use crate::display::DisplayState;
use log::{info, error};
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use tokio::sync::oneshot::error::TryRecvError;
use std::fs;
use std::thread::sleep;
use std::time::Duration;
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
// those coordinates were mainly chosen for a spot that doesn't get regularly updated by the main
// oledd service. otherwise we'd have to write to the display more than once per second to prevent
// the icon from flickering.
const STATUS_X: u8 = 104;
const STATUS_Y: u8 = 40;
const STATUS_W: u8 = 16;
const STATUS_H: u8 = 16;
const STATUS_HEADER: [u8; 4] = [STATUS_X, STATUS_Y, STATUS_W, STATUS_H];
macro_rules! pixel {
(x) => { 0 };
(_) => { 1 };
}
macro_rules! pixelart {
($($tt:tt)*) => {{
// could be improved to be const expr or at least to compile to something that doesn't
// allocate. but the macro is easier to write this way.
let mut bytes = Vec::new();
let mut i = 0;
let mut byte = 0;
$(
byte |= pixel!($tt);
if i == 7 {
bytes.push(byte);
i = 0;
byte = 0;
} else {
i += 1;
byte <<= 1;
}
)*
// last byte is bogus, discard it to silence warnings
let _byte = byte;
assert_eq!(i % 8, 0);
bytes
}}
}
fn paused() -> Vec<u8> {
let mut command = STATUS_HEADER.to_vec();
command.extend(pixelart! {
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
_ _ _ x x x x x x x x x x _ _ _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ _ _ x x x x x x x x x x _ _ _
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
});
command
}
fn smiling() -> Vec<u8> {
let mut command = STATUS_HEADER.to_vec();
command.extend(pixelart! {
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
_ _ _ x x x x x x x x x x _ _ _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ x x x x x x _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ _ _ x x x x x x x x x x _ _ _
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
});
command
}
fn frowning() -> Vec<u8> {
let mut command = STATUS_HEADER.to_vec();
command.extend(
pixelart! {
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
_ _ _ x x x x x x x x x x _ _ _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ x x x x x x _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ _ _ x x x x x x x x x x _ _ _
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
}
);
command
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
task_tracker.spawn_blocking(move || {
let mut pixels = smiling();
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
},
Err(TryRecvError::Empty) => {},
Err(e) => panic!("error receiving shutdown message: {e}")
}
match ui_update_rx.try_recv() {
Ok(DisplayState::Paused) => pixels = paused(),
Ok(DisplayState::Recording) => pixels = smiling(),
Ok(DisplayState::RecordingCBM) => pixels = smiling(),
Ok(DisplayState::WarningDetected) => pixels = frowning(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {},
Err(e) => {
error!("error receiving framebuffer update message: {e}");
}
};
// we write the status every second because it may have been overwritten through menu
// navigation.
if display_level != 0 {
if let Err(e) = fs::write(OLED_PATH, &pixels) {
error!("failed to write to display: {e}");
}
}
sleep(Duration::from_millis(1000));
}
});
}
#[test]
fn test_pixels() {
let pixels = frowning();
assert_eq!(pixels, [104, 40, 16, 16, 255, 255, 224, 7, 159, 249, 191, 253, 191, 253, 187, 221, 191, 253, 191, 253, 184, 29, 187, 221, 187, 221, 191, 253, 191, 253, 159, 249, 224, 7, 255, 255]);
}