diff --git a/.gitignore b/.gitignore index ea8c4bf..14f584a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target +/cache +*.png +.vscode \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 94f1b80..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "cSpell.words": [ - "Killmail", - "killpaper" - ] -} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e9a06bb..aade65b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -87,6 +93,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -256,6 +282,19 @@ dependencies = [ "byteorder", ] +[[package]] +name = "embedded-graphics-simulator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31606a4fb7d9d3a79a38d27bc2954cfa98682c8fea4b22c09a442785a80424e" +dependencies = [ + "base64", + "embedded-graphics", + "image", + "ouroboros", + "sdl2", +] + [[package]] name = "embedded-hal" version = "1.0.0" @@ -459,6 +498,12 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "http" version = "1.3.1" @@ -768,8 +813,10 @@ dependencies = [ name = "killpaper" version = "0.1.0" dependencies = [ + "bincode", "embedded-graphics", "embedded-graphics-core", + "embedded-graphics-simulator", "embedded-hal", "gpiocdev-embedded-hal", "image", @@ -779,6 +826,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.175" @@ -948,6 +1001,30 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1006,6 +1083,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + [[package]] name = "quinn" version = "0.11.6" @@ -1055,7 +1145,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1240,6 +1330,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdl2" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "sdl2-sys", +] + +[[package]] +name = "sdl2-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3" +dependencies = [ + "cfg-if", + "libc", + "version-compare", +] + [[package]] name = "security-framework" version = "3.3.0" @@ -1386,6 +1499,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "subtle" version = "2.6.1" @@ -1608,6 +1727,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.7" @@ -1626,6 +1751,24 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "want" version = "0.3.1" @@ -1842,6 +1985,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 15c05da..0f00b43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,12 @@ linux-embedded-hal = "0.4.0" reqwest = { version = "0.12", default-features = false, features = ["http2","rustls-tls-native-roots","json", "blocking", "gzip", "brotli"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.143" +bincode = "2.0.1" +embedded-graphics-simulator = "0.7.0" [features] -# Remove the linux-dev feature to build the tests on non unix systems -default = ["graphics"] -graphics = [] - +default = ["simulator"] # use simulator by default +simulator = [] +epaper = [] +paper-png = [] \ No newline at end of file diff --git a/killmails_display.png b/killmails_display.png deleted file mode 100644 index 5a29405..0000000 Binary files a/killmails_display.png and /dev/null differ diff --git a/output.png b/output.png deleted file mode 100644 index 8491aaf..0000000 Binary files a/output.png and /dev/null differ diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4349b02 --- /dev/null +++ b/readme.md @@ -0,0 +1,93 @@ + + +# Killpaper + +**Killpaper** is a Rust-based application for displaying EVE Online corporation kills and losses in real-time. It fetches killmails from zKillboard and EVE Online's ESI API, processes detailed kill information, and displays it on either a simulator or an e-paper display. + +--- + +## Features + +* Fetches recent kills and losses for your corporation. +* Aggregates detailed information about victims and attackers, including: + + * Character + * Corporation + * Alliance + * Ship +* Supports multiple display options: + + * Simulator (for testing and development) + * PNG rendering (paper-png feature) + * E-Paper display (hardware feature) +* Real-time updates via Redis queue integration. +* Easy to configure for any corporation. + +--- + +## Requirements + +* Rust 1.70+ + +--- + +## Installation + +Clone the repository: + +```bash +git clone https://git.revwal.de/oliver/killpaper.git +cd killpaper +``` + +Build with Cargo: + +```bash +cargo build --release +``` + +--- + +## Usage + +Run the application: + +```bash +cargo run --release +``` + +The display will be selected automatically based on compile-time features: + +* Simulator (default): `--features simulator` +* Paper PNG rendering: `--features paper-png` +* E-Paper hardware: `--features epaper` + +Example: + +```bash +cargo run --release --features epaper +``` + +--- + +## Configuration + +* Set your corporation ID in `main.rs`: + +```rust +let mut app = app::App::new(YOUR_CORP_ID, display); +``` + + +--- + +## Crate Modules + +* `app`: Main application logic for fetching, storing, and drawing kills. +* `display`: Display abstraction and implementations (simulator, e-paper). +* `killinfo`: Detailed kill information structures and builders. +* `model`: Data models for Characters, Corporations, Alliances, Ships, and Killmails. +* `services`: API clients for ESI, zKillboard, and Redis queue. +* `cache`: Optional caching utilities for faster repeated queries. +* `epd`: Optional e-paper display support (feature `epaper`). + diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..38b2eb4 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,213 @@ +//! # Main Application Module +//! +//! The [`App`] struct coordinates all core functionality of the killboard display system. +//! It manages: +//! +//! - **Data sources** +//! - [`EsiClient`](crate::services::EsiClient): Fetches detailed killmail info from the EVE ESI API. +//! - [`ZkillClient`](crate::services::ZkillClient): Queries historical kill/loss data from zKillboard. +//! - [`RedisQClient`](crate::services::RedisQClient): Streams live killmails in near real-time. +//! +//! - **Killmail processing** +//! - [`KillInfoBuilder`](crate::killinfo::KillInfoBuilder) composes rich [`KillInfo`](crate::killinfo::KillInfo) +//! objects from raw killmail requests. +//! +//! - **Display output** +//! - Any type implementing the [`KillDisplay`](crate::display::KillDisplay) trait, +//! such as [`EPaper`](crate::display::EPaper) or [`ImageSimulator`](crate::display::ImageSimulator). +//! +//! ## Lifecycle +//! 1. Create an [`App`] with [`App::new`]. +//! 2. Call [`App::init`] to fetch initial kills and losses (past 24h). +//! 3. Call [`App::draw`] to render the data on the display. +//! 4. Periodically call [`App::pull_update`] to fetch live updates from RedisQ. +//! +//! ## Example +//! ```no_run +//! use crate::app::App; +//! use crate::display::{KillDisplay, EPaper}; +//! +//! let corp_id = 123456; +//! let mut app = App::new(corp_id, EPaper::new()); +//! +//! app.init(); // Load last 24h +//! app.draw(); // Draw initial kills/losses +//! +//! loop { +//! if app.pull_update() { +//! app.draw(); // Redraw on update +//! } +//! } +//! ``` + +use crate::display::KillDisplay; +use crate::killinfo; +use crate::killinfo::KillInfo; +use crate::model::killmail::KillmailRequest; +use crate::services::{EsiClient, RedisQClient, Target, ZkillClient}; + +/// The main application struct tying together data sources, processing, and display. +pub struct App { + esi: EsiClient, + zkill: ZkillClient, + redisq: RedisQClient, + killbuilder: killinfo::KillInfoBuilder, + display: D, + corp_id: u32, + kills: Vec, + losses: Vec, +} + +/// Number of seconds in a day, used for initial historical queries. +const SECONDS_A_DAY: u32 = 60 * 60 * 24; + +impl App +where + D: KillDisplay, +{ + /// Creates a new [`App`] instance. + /// + /// # Arguments + /// * `my_corp_id` - The corporation ID to track. + /// * `display` - The display backend (e.g. [`EPaper`](crate::display::EPaper)). + pub fn new(my_corp_id: u32, display: D) -> Self { + let esi = EsiClient::new(); + let zkill = ZkillClient::new(); + let mut redisq = RedisQClient::new("todo_generate_random_number".to_string()); + let mut builder = killinfo::KillInfoBuilder::new(); + builder.set_corporation_id(my_corp_id); + redisq.set_corporation_id(my_corp_id); + + Self { + esi, + zkill, + redisq, + killbuilder: builder, + display, + corp_id: my_corp_id, + kills: Vec::new(), + losses: Vec::new(), + } + } + + /// Fetches recent kill requests (past 24 hours) from zKillboard. + fn get_initial_kill_requests(&mut self) -> Vec { + let response = self + .zkill + .get_corporation_kills(self.corp_id, SECONDS_A_DAY) + .unwrap(); + + // Return at most the first 8 elements + response.into_iter().take(8).collect() + } + + /// Fetches recent loss requests (past 24 hours) from zKillboard. + fn get_initial_loss_requests(&mut self) -> Vec { + let response = self + .zkill + .get_corporation_losses(self.corp_id, SECONDS_A_DAY) + .unwrap(); + + // Return at most the first 8 elements + response.into_iter().take(8).collect() + } + + /// Resolves the initial set of kills into [`KillInfo`] objects. + fn get_initial_kills(&mut self) -> Vec { + let requests = self.get_initial_kill_requests(); + let killmails = requests + .iter() + .map(|r| { + self.killbuilder + .make_kill_info(&self.zkill, &mut self.esi, r) + .unwrap() + }) + .collect(); + + killmails + } + + /// Resolves the initial set of losses into [`KillInfo`] objects. + fn get_initial_losses(&mut self) -> Vec { + let requests = self.get_initial_loss_requests(); + let killmails = requests + .iter() + .map(|r| { + self.killbuilder + .make_kill_info(&self.zkill, &mut self.esi, r) + .unwrap() + }) + .collect(); + + killmails + } + + /// Initializes the app by fetching and building the initial kills and losses. + pub fn init(&mut self) { + self.kills = self.get_initial_kills(); + self.losses = self.get_initial_losses(); + } + + /// Draws the current kills and losses on the display. + /// + /// - Kills are drawn in a vertical list starting at `x = 0`. + /// - Losses are drawn in a vertical list starting at `x = 400`. + pub fn draw(&mut self) { + self.display.clear(true); + + for (i, k) in self.kills.iter().enumerate() { + let ii = i as i32; + let y: i32 = ii * 60; + + self.display.draw_kill_info(k, 0, y); + } + + for (i, k) in self.losses.iter().enumerate() { + let ii = i as i32; + let y: i32 = ii * 60; + + self.display.draw_loss_info(k, 400, y); + } + + self.display.flush(); + } + + /// Pulls the next live update from RedisQ and integrates it into the kill/loss list. + /// + /// - If it’s a kill, it is prepended to the kills list. + /// - If it’s a loss, it is prepended to the losses list. + /// - Each list is capped at 8 entries. + /// + /// Returns `true` if an update was applied, `false` otherwise. + pub fn pull_update(&mut self) -> bool { + self.display.update(); + + if let Some((killrequest, target)) = self.redisq.next() { + let killmail = self + .killbuilder + .make_kill_info(&self.zkill, &mut self.esi, &killrequest) + .unwrap(); + + match target { + Target::Kill => { + self.kills.insert(0, killmail); // push to front + if self.kills.len() > 8 { + // limit length + self.kills.pop(); // remove last + } + return true; + } + Target::Loss => { + self.losses.insert(0, killmail); // push to front + if self.losses.len() > 8 { + // limit length + self.losses.pop(); // remove last + } + return true; + } + } + } + + return false; + } +} diff --git a/src/cache/image.rs b/src/cache/image.rs new file mode 100644 index 0000000..d4f2112 --- /dev/null +++ b/src/cache/image.rs @@ -0,0 +1,84 @@ +use image::{ImageError, ImageFormat, RgbImage}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +/// +/// A cache for storing and retrieving images by a unique ID. +/// Images are stored in a folder +/// +pub struct ImageCache { + path: String, + collection: BTreeSet, +} + +impl ImageCache { + /// Creates a new image cache from the specified directory path. + /// + /// # Arguments + /// * `path` - The path to the directory where images are stored. + pub fn new(path: String) -> Self { + let mut collection = BTreeSet::new(); + let dir_path = Path::new(&path); + + if let Ok(entries) = fs::read_dir(dir_path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() { + if let Some(file_name) = entry.file_name().to_str() { + // check if it ends with ".png" + if let Some(stem) = file_name.strip_suffix(".png") { + // try parse stem into u32 + if let Ok(id) = stem.parse::() { + collection.insert(id); + } + } + } + } + } + } + } + Self { path, collection } + } + + /// Constructs the file path for a given image ID. + /// + /// # Arguments + /// * `id` - The unique identifier of the image. + fn file_path(&self, id: u32) -> PathBuf { + Path::new(&self.path).join(format!("{:05}.png", id)) + } + + /// Loads an image by its ID if it exists in the cache. + /// + /// # Arguments + /// * `id` - The unique identifier of the image to look up. + pub fn lookup(&self, id: u32) -> Option { + if !self.collection.contains(&id) { + return None; + } + + let file_path = self.file_path(id); + match image::open(file_path) { + Ok(img) => Some(img.to_rgb8()), + Err(_) => None, + } + } + + /// Inserts an image into the cache and saves it as `{id:05}.png`. + /// + /// # Arguments + /// * `id` - The unique identifier for the image. + /// * `image` - A reference to the image to be inserted. + pub fn insert(&mut self, id: u32, image: &RgbImage) -> Result<(), ImageError> { + let file_path = self.file_path(id); + + // Save the image + image.save_with_format(&file_path, ImageFormat::Png)?; + + // Add to collection + self.collection.insert(id); + + Ok(()) + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..237e3ab --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,36 @@ +use std::collections::BTreeMap; + +pub mod image; + +/// +/// A simple cache that stores items with a unique ID. +/// +pub struct SimpleCache { + collection: BTreeMap, +} + +impl SimpleCache { + /// Create new Empty Cache + pub fn new() -> Self { + Self { + collection: BTreeMap::new(), + } + } + + /// Inserts a value into the cache with the given ID. + /// + /// # Arguments + /// * `id` - The unique identifier for the value. + /// * `value` - A reference to the value to be inserted. + pub fn insert(&mut self, id: u32, value: &T) { + self.collection.insert(id, value.clone()); + } + + /// Looks up a value by its ID and returns it if found. + /// + /// # Arguments + /// * `id` - The unique identifier of the value to look up. + pub fn lookup(&self, id: u32) -> Option { + self.collection.get(&id).cloned() + } +} diff --git a/src/epaper.rs b/src/display/epaper.rs similarity index 94% rename from src/epaper.rs rename to src/display/epaper.rs index 8955532..2b538b5 100644 --- a/src/epaper.rs +++ b/src/display/epaper.rs @@ -5,11 +5,11 @@ use embedded_graphics::{ text::{Baseline, Text, TextStyleBuilder}, }; -use crate::epd::color::Color; use crate::epd::epd7in5_v2::Display7in5; use crate::epd::epd7in5_v2::Epd7in5; use crate::epd::graphics::Display; use crate::epd::traits::*; +use crate::{display::KillDisplay, epd::color::Color}; use linux_embedded_hal::{ Delay, SPIError, SpidevDevice, @@ -69,24 +69,27 @@ impl EPaper { }); } - pub fn flush(&mut self) -> Result<(), SPIError> { - self.epd - .update_and_display_frame(&mut self.spi, self.display.buffer(), &mut self.delay)?; - Ok(()) - } - pub fn sleep(&mut self) -> Result<(), SPIError> { self.epd.sleep(&mut self.spi, &mut self.delay)?; Ok(()) } +} - pub fn clear(&mut self, on: bool) { +impl KillDisplay for EPaper { + fn flush(&mut self) { + let _ = self.epd.update_and_display_frame( + &mut self.spi, + self.display.buffer(), + &mut self.delay, + ); + } + + fn clear(&mut self, on: bool) { let color = if on { Color::Black } else { Color::White }; - self.display.clear(color).ok(); } - pub fn draw_text(&mut self, x: i32, y: i32, text: &str) { + fn draw_text(&mut self, x: i32, y: i32, text: &str) { let style = MonoTextStyleBuilder::new() .font(&embedded_graphics::mono_font::ascii::FONT_8X13) .text_color(Color::White) @@ -99,7 +102,7 @@ impl EPaper { .draw(&mut self.display); } - pub fn draw_kill_info(&mut self, kill: &KillInfo, x: i32, y: i32) { + fn draw_kill_info(&mut self, kill: &KillInfo, x: i32, y: i32) { let mut current_y = y; // Draw Alliance logo (64x64) if present @@ -174,7 +177,7 @@ impl EPaper { .ok(); } - pub fn draw_loss_info(&mut self, kill: &KillInfo, x: i32, y: i32) { + fn draw_loss_info(&mut self, kill: &KillInfo, x: i32, y: i32) { let mut current_y = y; // Draw Alliance logo (64x64) if present diff --git a/src/display.rs b/src/display/image_sim.rs similarity index 62% rename from src/display.rs rename to src/display/image_sim.rs index d7953a3..16c7d9a 100644 --- a/src/display.rs +++ b/src/display/image_sim.rs @@ -10,20 +10,20 @@ use embedded_graphics::{ use image::GrayImage; use image::RgbImage; -use crate::killinfo::KillInfo; +use crate::{display::KillDisplay, killinfo::KillInfo}; use embedded_graphics::pixelcolor::BinaryColor; use embedded_graphics::pixelcolor::raw::RawU1; const WIDTH: usize = 800; const HEIGHT: usize = 480; -pub struct Display { +pub struct ImageSimulator { width: usize, height: usize, buffer: Framebuffer, } -impl Display { +impl ImageSimulator { pub fn new() -> Self { Self { width: WIDTH, @@ -33,15 +33,6 @@ impl Display { ), } } - pub fn clear(&mut self, on: bool) { - let color = if on { - BinaryColor::On - } else { - BinaryColor::Off - }; - - self.buffer.clear(color).ok(); - } pub fn draw_rect(&mut self, x: i32, y: i32, w: u32, h: u32, color: u8) { let rect = Rectangle::new(Point::new(x, y), Size::new(w, h)); @@ -203,8 +194,10 @@ impl Display { .draw(&mut self.buffer) .ok(); } +} - pub fn flush(&self) { +impl KillDisplay for ImageSimulator { + fn flush(&mut self) { let buf = self.buffer.data(); let img_data: Vec = buf .iter() @@ -216,6 +209,162 @@ impl Display { img.save("output.png").unwrap(); println!("Saved framebuffer to {:?}", "output.png"); } + + fn clear(&mut self, on: bool) { + let color = if on { + BinaryColor::On + } else { + BinaryColor::Off + }; + + self.buffer.clear(color).ok(); + } + + fn draw_text(&mut self, x: i32, y: i32, text: &str) { + let bw = BinaryColor::On; + let style = MonoTextStyle::new(&FONT_8X13, bw); + Text::new(text, Point::new(x, y), style) + .draw(&mut self.buffer) + .ok(); + } + + fn draw_kill_info(&mut self, kill: &KillInfo, x: i32, y: i32) { + let mut current_y = y; + + // Draw Alliance logo (64x64) if present + if let Some(alliance) = &kill.victim.alliance { + let bw_vec = rgb_to_bw_dithered(&alliance.logo); + let logo_raw = ImageRaw::::new(&bw_vec, alliance.logo.width() as u32); + Image::new(&logo_raw, Point::new(x, y)) + .draw(&mut self.buffer) + .ok(); + } + + // Draw Ship icon (64x64) if present + if let Some(ship) = &kill.victim.ship { + let bw_vec = rgb_to_bw_dithered(&ship.icon); + let logo_raw = ImageRaw::::new(&bw_vec, ship.icon.width() as u32); + Image::new(&logo_raw, Point::new(x + 64 + 4, current_y)) + .draw(&mut self.buffer) + .ok(); + } + + let text_x = x + 64 + 64 + 8; + let style = MonoTextStyle::new(&FONT_8X13, BinaryColor::Off); + current_y += 8; + + // Character name + Alliance short + let alliance_short = kill + .victim + .alliance + .as_ref() + .map(|a| a.short.as_str()) + .unwrap_or(""); + + let char_name = kill + .victim + .character + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or(""); + + let char_line = format!("{} [{}]", char_name, alliance_short); + Text::new(&char_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // Ship name + total value + let ship_name = kill + .victim + .ship + .as_ref() + .map(|s| s.name.as_str()) + .unwrap_or("Unknown"); + let value_line = format!( + "{} - {:.2}M ISK", + ship_name, + kill.total_value / 1_000_000f64 + ); + Text::new(&value_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // System name + Text::new(&kill.system_name, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + } + + fn draw_loss_info(&mut self, kill: &KillInfo, x: i32, y: i32) { + let mut current_y = y; + + // Draw Alliance logo (64x64) if present + if let Some(character) = &kill.victim.character { + let bw_vec = rgb_to_bw_dithered(&character.portrait); + let logo_raw = ImageRaw::::new(&bw_vec, character.portrait.width() as u32); + Image::new(&logo_raw, Point::new(x, y)) + .draw(&mut self.buffer) + .ok(); + } + + // Draw Ship icon (64x64) if present + if let Some(ship) = &kill.victim.ship { + let bw_vec = rgb_to_bw_dithered(&ship.icon); + let logo_raw = ImageRaw::::new(&bw_vec, ship.icon.width() as u32); + Image::new(&logo_raw, Point::new(x + 64 + 4, current_y)) + .draw(&mut self.buffer) + .ok(); + } + + let text_x = x + 64 + 64 + 8; + let style = MonoTextStyle::new(&FONT_8X13, BinaryColor::Off); + current_y += 8; + + // Character name + Alliance short + let alliance_short = kill + .victim + .alliance + .as_ref() + .map(|a| a.short.as_str()) + .unwrap_or(""); + + let char_name = kill + .victim + .character + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or(""); + + let char_line = format!("{} [{}]", char_name, alliance_short); + Text::new(&char_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // Ship name + total value + let ship_name = kill + .victim + .ship + .as_ref() + .map(|s| s.name.as_str()) + .unwrap_or("Unknown"); + let value_line = format!( + "{} - {:.2}M ISK", + ship_name, + kill.total_value / 1_000_000f64 + ); + Text::new(&value_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // System name + Text::new(&kill.system_name, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + } } fn rgb_to_bw_dithered(rgb: &RgbImage) -> Vec { diff --git a/src/display/mod.rs b/src/display/mod.rs new file mode 100644 index 0000000..9610447 --- /dev/null +++ b/src/display/mod.rs @@ -0,0 +1,54 @@ +use crate::killinfo::KillInfo; + +/// A trait for displaying killmail information on various output devices. +/// +/// Implementations may include physical hardware (like e-paper screens) +/// or simulated environments (like images for debugging). +pub trait KillDisplay { + /// Clears the display. + /// + /// # Arguments + /// * `on` - If `true`, the display is cleared to white; if `false`, to black (if supported). + fn clear(&mut self, on: bool); + + /// Draws arbitrary text on the display. + /// + /// # Arguments + /// * `x` - X-coordinate in pixels. + /// * `y` - Y-coordinate in pixels. + /// * `text` - The text string to render. + fn draw_text(&mut self, x: i32, y: i32, text: &str); + + /// Draws a killmail summary as a "kill" (when the tracked corporation scored the kill). + /// + /// # Arguments + /// * `kill` - Killmail data to render. + /// * `x` - X-coordinate in pixels. + /// * `y` - Y-coordinate in pixels. + fn draw_kill_info(&mut self, kill: &KillInfo, x: i32, y: i32); + + /// Draws a killmail summary as a "loss" (when the tracked corporation suffered the loss). + /// + /// # Arguments + /// * `kill` - Killmail data to render. + /// * `x` - X-coordinate in pixels. + /// * `y` - Y-coordinate in pixels. + fn draw_loss_info(&mut self, kill: &KillInfo, x: i32, y: i32); + + /// Finalizes any pending drawing operations and updates the display. + fn flush(&mut self); + + // update needed for more interactive displays; + fn update(&mut self) {} +} + +#[cfg(feature = "epaper")] +mod epaper; +#[cfg(feature = "epaper")] +pub use epaper::EPaper; + +mod image_sim; +mod simulator; + +pub use image_sim::ImageSimulator; +pub use simulator::Simulator; diff --git a/src/display/simulator.rs b/src/display/simulator.rs new file mode 100644 index 0000000..9fc7c77 --- /dev/null +++ b/src/display/simulator.rs @@ -0,0 +1,379 @@ +use embedded_graphics::{ + image::{Image, ImageRaw}, + mono_font::{MonoTextStyle, ascii::FONT_8X13}, + prelude::*, + primitives::{PrimitiveStyle, Rectangle, StyledDrawable}, + text::Text, +}; +use image::RgbImage; + +use crate::{display::KillDisplay, killinfo::KillInfo}; +use embedded_graphics::pixelcolor::Gray8; + +use embedded_graphics_simulator::{OutputSettingsBuilder, SimulatorDisplay, Window}; + +const WIDTH: usize = 800; +const HEIGHT: usize = 480; + +pub struct Simulator { + _width: usize, + _height: usize, + buffer: SimulatorDisplay, + window: Window, +} + +impl Simulator { + pub fn new() -> Self { + let display = SimulatorDisplay::::new(Size::new(WIDTH as u32, HEIGHT as u32)); + + let output_settings = OutputSettingsBuilder::new() + .scale(1) + .pixel_spacing(0) + .build(); + + let window = Window::new("Killpaper", &output_settings); + + Self { + _width: WIDTH, + _height: HEIGHT, + buffer: display, + window, + } + } + + pub fn draw_rect(&mut self, x: i32, y: i32, w: u32, h: u32, color: u8) { + let rect = Rectangle::new(Point::new(x, y), Size::new(w, h)); + let style = PrimitiveStyle::with_fill(Gray8::new(color)); + rect.draw_styled(&style, &mut self.buffer).ok(); + } + + pub fn draw_text(&mut self, x: i32, y: i32, text: &str, color: u8) { + let style = MonoTextStyle::new(&FONT_8X13, Gray8::new(color)); + Text::new(text, Point::new(x, y), style) + .draw(&mut self.buffer) + .ok(); + } + + pub fn draw_kill_info(&mut self, kill: &KillInfo, x: i32, y: i32) { + let mut current_y = y; + + // Draw Alliance logo (64x64) if present + if let Some(alliance) = &kill.victim.alliance { + let bw_vec = rgbimage_to_gray8(&alliance.logo); + let logo_raw = ImageRaw::::new(&bw_vec, alliance.logo.width() as u32); + Image::new(&logo_raw, Point::new(x, y)) + .draw(&mut self.buffer) + .ok(); + } + + // Draw Ship icon (64x64) if present + if let Some(ship) = &kill.victim.ship { + let bw_vec = rgbimage_to_gray8(&ship.icon); + let logo_raw = ImageRaw::::new(&bw_vec, ship.icon.width() as u32); + Image::new(&logo_raw, Point::new(x + 64 + 4, current_y)) + .draw(&mut self.buffer) + .ok(); + } + + let text_x = x + 64 + 64 + 8; + let style = MonoTextStyle::new(&FONT_8X13, Gray8::BLACK); + current_y += 8; + + // Character name + Alliance short + let alliance_short = kill + .victim + .alliance + .as_ref() + .map(|a| a.short.as_str()) + .unwrap_or(""); + + let char_name = kill + .victim + .character + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or(""); + + let char_line = format!("{} [{}]", char_name, alliance_short); + Text::new(&char_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // Ship name + total value + let ship_name = kill + .victim + .ship + .as_ref() + .map(|s| s.name.as_str()) + .unwrap_or("Unknown"); + let value_line = format!( + "{} - {:.2}M ISK", + ship_name, + kill.total_value / 1_000_000f64 + ); + Text::new(&value_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // System name + Text::new(&kill.system_name, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + } + + pub fn draw_loss_info(&mut self, kill: &KillInfo, x: i32, y: i32) { + let mut current_y = y; + + // Draw Alliance logo (64x64) if present + if let Some(character) = &kill.victim.character { + let bw_vec = rgbimage_to_gray8(&character.portrait); + let logo_raw = ImageRaw::::new(&bw_vec, character.portrait.width() as u32); + Image::new(&logo_raw, Point::new(x, y)) + .draw(&mut self.buffer) + .ok(); + } + + // Draw Ship icon (64x64) if present + if let Some(ship) = &kill.victim.ship { + let bw_vec = rgbimage_to_gray8(&ship.icon); + let logo_raw = ImageRaw::::new(&bw_vec, ship.icon.width() as u32); + Image::new(&logo_raw, Point::new(x + 64 + 4, current_y)) + .draw(&mut self.buffer) + .ok(); + } + + let text_x = x + 64 + 64 + 8; + let style = MonoTextStyle::new(&FONT_8X13, Gray8::BLACK); + current_y += 8; + + // Character name + Alliance short + let alliance_short = kill + .victim + .alliance + .as_ref() + .map(|a| a.short.as_str()) + .unwrap_or(""); + + let char_name = kill + .victim + .character + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or(""); + + let char_line = format!("{} [{}]", char_name, alliance_short); + Text::new(&char_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // Ship name + total value + let ship_name = kill + .victim + .ship + .as_ref() + .map(|s| s.name.as_str()) + .unwrap_or("Unknown"); + let value_line = format!( + "{} - {:.2}M ISK", + ship_name, + kill.total_value / 1_000_000f64 + ); + Text::new(&value_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // System name + Text::new(&kill.system_name, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + } +} + +impl KillDisplay for Simulator { + fn flush(&mut self) { + self.window.update(&self.buffer); + } + + fn update(&mut self) { + self.window.update(&self.buffer); + + if self + .window + .events() + .any(|e| e == embedded_graphics_simulator::SimulatorEvent::Quit) + { + std::process::exit(0); + } + } + + fn clear(&mut self, on: bool) { + let color = if on { Gray8::WHITE } else { Gray8::BLACK }; + + self.buffer.clear(color).ok(); + } + + fn draw_text(&mut self, x: i32, y: i32, text: &str) { + let bw = Gray8::BLACK; + let style = MonoTextStyle::new(&FONT_8X13, bw); + Text::new(text, Point::new(x, y), style) + .draw(&mut self.buffer) + .ok(); + } + + fn draw_kill_info(&mut self, kill: &KillInfo, x: i32, y: i32) { + let mut current_y = y; + + // Draw Alliance logo (64x64) if present + if let Some(alliance) = &kill.victim.alliance { + let bw_vec = rgbimage_to_gray8(&alliance.logo); + let logo_raw = ImageRaw::::new(&bw_vec, alliance.logo.width() as u32); + Image::new(&logo_raw, Point::new(x, y)) + .draw(&mut self.buffer) + .ok(); + } + + // Draw Ship icon (64x64) if present + if let Some(ship) = &kill.victim.ship { + let bw_vec = rgbimage_to_gray8(&ship.icon); + let logo_raw = ImageRaw::::new(&bw_vec, ship.icon.width() as u32); + Image::new(&logo_raw, Point::new(x + 64 + 4, current_y)) + .draw(&mut self.buffer) + .ok(); + } + + let text_x = x + 64 + 64 + 8; + let style = MonoTextStyle::new(&FONT_8X13, Gray8::BLACK); + current_y += 8; + + // Character name + Alliance short + let alliance_short = kill + .victim + .alliance + .as_ref() + .map(|a| a.short.as_str()) + .unwrap_or(""); + + let char_name = kill + .victim + .character + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or(""); + + let char_line = format!("{} [{}]", char_name, alliance_short); + Text::new(&char_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // Ship name + total value + let ship_name = kill + .victim + .ship + .as_ref() + .map(|s| s.name.as_str()) + .unwrap_or("Unknown"); + let value_line = format!( + "{} - {:.2}M ISK", + ship_name, + kill.total_value / 1_000_000f64 + ); + Text::new(&value_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // System name + Text::new(&kill.system_name, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + } + + fn draw_loss_info(&mut self, kill: &KillInfo, x: i32, y: i32) { + let mut current_y = y; + + // Draw Alliance logo (64x64) if present + if let Some(character) = &kill.victim.character { + let bw_vec = rgbimage_to_gray8(&character.portrait); + let logo_raw = ImageRaw::::new(&bw_vec, character.portrait.width() as u32); + Image::new(&logo_raw, Point::new(x, y)) + .draw(&mut self.buffer) + .ok(); + } + + // Draw Ship icon (64x64) if present + if let Some(ship) = &kill.victim.ship { + let bw_vec = rgbimage_to_gray8(&ship.icon); + let logo_raw = ImageRaw::::new(&bw_vec, ship.icon.width() as u32); + Image::new(&logo_raw, Point::new(x + 64 + 4, current_y)) + .draw(&mut self.buffer) + .ok(); + } + + let text_x = x + 64 + 64 + 8; + let style = MonoTextStyle::new(&FONT_8X13, Gray8::BLACK); + current_y += 8; + + // Character name + Alliance short + let alliance_short = kill + .victim + .alliance + .as_ref() + .map(|a| a.short.as_str()) + .unwrap_or(""); + + let char_name = kill + .victim + .character + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or(""); + + let char_line = format!("{} [{}]", char_name, alliance_short); + Text::new(&char_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // Ship name + total value + let ship_name = kill + .victim + .ship + .as_ref() + .map(|s| s.name.as_str()) + .unwrap_or("Unknown"); + let value_line = format!( + "{} - {:.2}M ISK", + ship_name, + kill.total_value / 1_000_000f64 + ); + Text::new(&value_line, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + current_y += 16; + + // System name + Text::new(&kill.system_name, Point::new(text_x, current_y), style) + .draw(&mut self.buffer) + .ok(); + } +} + +fn rgbimage_to_gray8(raw_img: &RgbImage) -> Vec { + let (width, height) = raw_img.dimensions(); + + // Convert RGB → grayscale (luma) + let mut gray_data = Vec::with_capacity((width * height) as usize); + for pixel in raw_img.pixels() { + let [r, g, b] = pixel.0; + // Standard luminance formula + let luma = (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) as u8; + gray_data.push(luma); + } + + return gray_data; +} diff --git a/src/epd/color.rs b/src/epd/color.rs index 1b1e21a..5ca91ed 100644 --- a/src/epd/color.rs +++ b/src/epd/color.rs @@ -3,9 +3,7 @@ //! EPD representation of multicolor with separate buffers //! for each bit makes it hard to properly represent colors here -#[cfg(feature = "graphics")] use embedded_graphics::pixelcolor::BinaryColor; -#[cfg(feature = "graphics")] use embedded_graphics::pixelcolor::PixelColor; /// When trying to parse u8 to one of the color types @@ -133,7 +131,6 @@ impl ColorType for OctColor { } } -#[cfg(feature = "graphics")] impl From for OctColor { fn from(b: BinaryColor) -> OctColor { match b { @@ -143,7 +140,6 @@ impl From for OctColor { } } -#[cfg(feature = "graphics")] impl From for embedded_graphics::pixelcolor::Rgb888 { fn from(b: OctColor) -> Self { let (r, g, b) = b.rgb(); @@ -151,7 +147,6 @@ impl From for embedded_graphics::pixelcolor::Rgb888 { } } -#[cfg(feature = "graphics")] impl From for OctColor { fn from(p: embedded_graphics::pixelcolor::Rgb888) -> OctColor { use embedded_graphics::prelude::RgbColor; @@ -186,7 +181,6 @@ impl From for OctColor { } } -#[cfg(feature = "graphics")] impl From for OctColor { fn from(b: embedded_graphics::pixelcolor::raw::RawU4) -> Self { use embedded_graphics::prelude::RawData; @@ -194,7 +188,6 @@ impl From for OctColor { } } -#[cfg(feature = "graphics")] impl PixelColor for OctColor { type Raw = embedded_graphics::pixelcolor::raw::RawU4; } @@ -291,7 +284,6 @@ impl From for Color { } } -#[cfg(feature = "graphics")] impl From for Color { fn from(b: embedded_graphics::pixelcolor::raw::RawU1) -> Self { use embedded_graphics::prelude::RawData; @@ -303,19 +295,16 @@ impl From for Color { } } -#[cfg(feature = "graphics")] impl From for embedded_graphics::pixelcolor::raw::RawU1 { fn from(color: Color) -> Self { Self::new(color.get_bit_value()) } } -#[cfg(feature = "graphics")] impl PixelColor for Color { type Raw = embedded_graphics::pixelcolor::raw::RawU1; } -#[cfg(feature = "graphics")] impl From for Color { fn from(b: BinaryColor) -> Color { match b { @@ -325,7 +314,6 @@ impl From for Color { } } -#[cfg(feature = "graphics")] impl From for Color { fn from(rgb: embedded_graphics::pixelcolor::Rgb888) -> Self { use embedded_graphics::pixelcolor::RgbColor; @@ -344,7 +332,6 @@ impl From for Color { } } -#[cfg(feature = "graphics")] impl From for embedded_graphics::pixelcolor::Rgb888 { fn from(color: Color) -> Self { use embedded_graphics::pixelcolor::RgbColor; @@ -355,7 +342,6 @@ impl From for embedded_graphics::pixelcolor::Rgb888 { } } -#[cfg(feature = "graphics")] impl From for Color { fn from(rgb: embedded_graphics::pixelcolor::Rgb565) -> Self { use embedded_graphics::pixelcolor::RgbColor; @@ -374,7 +360,6 @@ impl From for Color { } } -#[cfg(feature = "graphics")] impl From for embedded_graphics::pixelcolor::Rgb565 { fn from(color: Color) -> Self { use embedded_graphics::pixelcolor::RgbColor; @@ -385,7 +370,6 @@ impl From for embedded_graphics::pixelcolor::Rgb565 { } } -#[cfg(feature = "graphics")] impl From for Color { fn from(rgb: embedded_graphics::pixelcolor::Rgb555) -> Self { use embedded_graphics::pixelcolor::RgbColor; @@ -404,7 +388,6 @@ impl From for Color { } } -#[cfg(feature = "graphics")] impl From for embedded_graphics::pixelcolor::Rgb555 { fn from(color: Color) -> Self { use embedded_graphics::pixelcolor::RgbColor; @@ -433,7 +416,6 @@ impl TriColor { } } -#[cfg(feature = "graphics")] impl From for TriColor { fn from(b: embedded_graphics::pixelcolor::raw::RawU2) -> Self { use embedded_graphics::prelude::RawData; @@ -447,12 +429,10 @@ impl From for TriColor { } } -#[cfg(feature = "graphics")] impl PixelColor for TriColor { type Raw = embedded_graphics::pixelcolor::raw::RawU2; } -#[cfg(feature = "graphics")] impl From for TriColor { fn from(b: BinaryColor) -> TriColor { match b { @@ -461,7 +441,6 @@ impl From for TriColor { } } } -#[cfg(feature = "graphics")] impl From for TriColor { fn from(rgb: embedded_graphics::pixelcolor::Rgb888) -> Self { use embedded_graphics::pixelcolor::RgbColor; @@ -475,7 +454,6 @@ impl From for TriColor { } } } -#[cfg(feature = "graphics")] impl From for embedded_graphics::pixelcolor::Rgb888 { fn from(tri_color: TriColor) -> Self { use embedded_graphics::pixelcolor::RgbColor; diff --git a/src/epd/epd7in5_v2/mod.rs b/src/epd/epd7in5_v2/mod.rs index 1fc14f2..9f3f36b 100644 --- a/src/epd/epd7in5_v2/mod.rs +++ b/src/epd/epd7in5_v2/mod.rs @@ -25,7 +25,6 @@ use self::command::Command; use crate::buffer_len; /// Full size buffer for use with the 7in5 v2 EPD -#[cfg(feature = "graphics")] pub type Display7in5 = crate::epd::graphics::Display< WIDTH, HEIGHT, diff --git a/src/killinfo.rs b/src/killinfo.rs index db19719..264dbd2 100644 --- a/src/killinfo.rs +++ b/src/killinfo.rs @@ -1,43 +1,76 @@ -use crate::model::alliance::Alliance; -use crate::model::character::Character; -use crate::model::corporation::Corporation; -use crate::model::killmail::{KillmailAttacker, KillmailRequest}; -use crate::model::ship::Ship; -use crate::services::esi_static::{EsiClient, EsiError}; -use crate::services::zkill::{ZkillClient, ZkillError}; +//! # Kill Info Module +//! +//! This module provides types and functionality to convert raw killmail data +//! into structured, rich information about kills and losses in EVE Online. +//! +//! It leverages both `ZkillClient` (for killmail retrieval) and `EsiClient` +//! (for detailed character, corporation, alliance, and ship info). +//! +//! ## Key Types +//! - [`Individual`]: Represents a single participant (victim or attacker) in a killmail. +//! - [`KillInfo`]: Aggregates information about a kill, including victim, main attacker, system, and total ISK value. +//! - [`KillInfoBuilder`]: Helper to build `KillInfo` instances, prioritizing attackers from a specific corporation. +use crate::model::Alliance; +use crate::model::Character; +use crate::model::Corporation; +use crate::model::Ship; +use crate::model::killmail::{KillmailAttacker, KillmailRequest}; +use crate::services::{EsiClient, EsiError, ZkillClient, ZkillError}; + +/// Represents a participant (victim or attacker) in a killmail. pub struct Individual { + /// Optional character info (may be None if unavailable) pub character: Option, + /// Optional alliance info (may be None if unavailable) pub alliance: Option, + /// The corporation of the participant (always available) pub corporation: Corporation, + /// Optional ship info (may be None if unavailable) pub ship: Option, } +/// Contains full information about a single kill or loss. pub struct KillInfo { + /// The victim of the kill pub victim: Individual, + /// The primary attacker (may be None if undetermined e.g. NPC) pub main_attacker: Option, + /// The solar system where the kill occurred pub system_name: String, + /// Total value of the destroyed assets pub total_value: f64, } +/// Builder for [`KillInfo`] objects, allowing prioritization of attackers from a specific corporation. pub struct KillInfoBuilder { corporation_id: u32, } impl KillInfoBuilder { + /// Creates a new builder with default settings. pub fn new() -> KillInfoBuilder { KillInfoBuilder { corporation_id: 0 } } - // used to prioritize attacker with this id + /// Sets the corporation ID to prioritize when selecting the main attacker. pub fn set_corporation_id(&mut self, id: u32) { self.corporation_id = id; } + /// Constructs a [`KillInfo`] object from a [`KillmailRequest`] using `ZkillClient` and `EsiClient`. + /// + /// # Arguments + /// * `client` - The `ZkillClient` used to fetch detailed killmail data. + /// * `esi` - Mutable reference to `EsiClient` for additional lookups. + /// * `request` - The raw killmail request from zKillboard. + /// + /// # Returns + /// `Result` containing the fully resolved kill information. pub fn make_kill_info( &self, client: &ZkillClient, - esi: &EsiClient, + esi: &mut EsiClient, request: &KillmailRequest, ) -> Result { let total_value = request.value; @@ -79,6 +112,10 @@ impl KillInfoBuilder { }) } + /// Determines the main attacker from a list of killmail attackers. + /// + /// Prefers attackers from the configured corporation; otherwise, chooses + /// the attacker with the highest damage done. pub fn find_main_attacker( &self, attacker_list: &Vec, @@ -104,8 +141,19 @@ impl KillInfoBuilder { } impl Individual { + /// Builds an [`Individual`] from raw IDs using [`EsiClient`] lookups. + /// + /// # Arguments + /// * `client` - Mutable reference to `EsiClient` for API queries. + /// * `char_id` - Character ID (0 if unavailable) + /// * `corp_id` - Corporation ID + /// * `alli_id` - Alliance ID (0 if unavailable) + /// * `ship_id` - Ship ID (0 if unavailable) + /// + /// # Returns + /// `Result` containing the constructed participant. pub fn build_with_esi( - client: &EsiClient, + client: &mut EsiClient, char_id: u32, corp_id: u32, alli_id: u32, diff --git a/src/lib.rs b/src/lib.rs index 5ab7042..207e1ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,6 @@ +pub mod app; +pub mod cache; +pub mod display; pub mod epd; pub mod killinfo; pub mod model; diff --git a/src/main.rs b/src/main.rs index 07aa0af..e8d16e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,70 +1,52 @@ +//! # Main Entry Point +//! +//! This crate is an EVE Online killboard client and display application. +//! It fetches killmails for a corporation using zKillboard and ESI, +//! builds detailed kill information, and displays it either on a simulator +//! or an e-paper display, depending on compile-time features. +//! +//! ## Modules +//! - `cache`: Utilities for caching data. +//! - `display`: Display abstractions and implementations (simulator or e-paper). +//! - `killinfo`: Structures and builders for detailed kill information. +//! - `model`: Core data models (Character, Corporation, Alliance, Ship, Killmail). +//! - `services`: Clients for ESI, zKillboard, and Redis queue. +//! - `app`: Main application logic for fetching, storing, and drawing kills. +//! - `epd` (optional): E-Paper display support (feature `epaper`). + +pub mod cache; pub mod display; -pub mod epaper; -pub mod epd; + +pub mod app; pub mod killinfo; pub mod model; pub mod services; -use crate::killinfo::KillInfo; -use crate::services::esi_static::EsiClient; -use crate::services::zkill::ZkillClient; +#[cfg(feature = "epaper")] +pub mod epd; pub const fn buffer_len(width: usize, height: usize) -> usize { (width + 7) / 8 * height } -fn main() { - let esi = EsiClient::new(); - let zkill = ZkillClient::new(); +fn main() -> () { + // Pick the display based on compile-time feature + #[cfg(feature = "simulator")] + let display = display::Simulator::new(); - let past_seconds = 60 * 60 * 24; - let my_corp_id = 98685373; + #[cfg(feature = "paper-png")] + let display = display::Simulator::new(); - let mut display = display::Display::new(); - let mut epaper = epaper::EPaper::new().expect("DisplayError"); + #[cfg(feature = "epaper")] + let display = display::EPaper::new(); - display.clear(true); - epaper.clear(true); - epaper.flush().expect("flush error"); + let mut app = app::App::new(98685373, display); + app.init(); + app.draw(); - let response = zkill - .get_corporation_kills(my_corp_id, past_seconds) - .unwrap(); - let mut builder = killinfo::KillInfoBuilder::new(); - builder.set_corporation_id(my_corp_id); - - let killmails: Vec = response - .iter() - .map(|r| builder.make_kill_info(&zkill, &esi, r).unwrap()) - .collect(); - - for (i, k) in killmails.iter().enumerate() { - let ii = i as i32; - let y: i32 = ii * 60; - - display.draw_kill_info(k, 0, y); - epaper.draw_kill_info(k, 0, y); + loop { + if app.pull_update() { + app.draw(); + } } - - let response = zkill - .get_corporation_losses(my_corp_id, past_seconds) - .unwrap(); - let mut builder = killinfo::KillInfoBuilder::new(); - builder.set_corporation_id(my_corp_id); - - let killmails: Vec = response - .iter() - .map(|r| builder.make_kill_info(&zkill, &esi, r).unwrap()) - .collect(); - - for (i, k) in killmails.iter().enumerate() { - let ii = i as i32; - let y: i32 = ii * 60; - - display.draw_loss_info(k, 400, y); - epaper.draw_loss_info(k, 400, y); - } - - display.flush(); - epaper.flush().expect("flush error"); } diff --git a/src/model/alliance.rs b/src/model/alliance.rs index 578f894..baa07f8 100644 --- a/src/model/alliance.rs +++ b/src/model/alliance.rs @@ -1,9 +1,14 @@ use image::RgbImage; +/// Represents an alliance with a unique ID, name, short name and logo. #[derive(Debug, Clone)] pub struct Alliance { + /// The unique ID of the alliance. pub alliance_id: u32, + /// The name of the alliance. pub name: String, + /// The short name of the alliance. pub short: String, + /// The logo image of the alliance in RGB format. pub logo: RgbImage, } diff --git a/src/model/character.rs b/src/model/character.rs index f57d1ca..560281d 100644 --- a/src/model/character.rs +++ b/src/model/character.rs @@ -1,8 +1,14 @@ use image::RgbImage; +/// Represents a character in a game or other media. +/// +/// A `Character` has an ID, a name, and a portrait image. The portrait should be a colored image with RGB color channels. #[derive(Debug, Clone)] pub struct Character { + /// The unique identifier for this character. pub character_id: u32, + /// The name of the character as a string. pub name: String, + /// The portrait image of the character as an RGB image. pub portrait: RgbImage, } diff --git a/src/model/corporation.rs b/src/model/corporation.rs index ac14dfc..5838a50 100644 --- a/src/model/corporation.rs +++ b/src/model/corporation.rs @@ -1,9 +1,17 @@ use image::RgbImage; +/// Represents a corporation with its details. #[derive(Debug, Clone)] pub struct Corporation { + /// The unique identifier of the corporation. pub corporation_id: u32, + + /// The name of the corporation. pub name: String, + + /// A short version or acronym for the corporation's name. pub short: String, + + /// The logo image associated with the corporation, represented as an RGB image. pub logo: RgbImage, } diff --git a/src/model/killmail.rs b/src/model/killmail.rs index 2d4dd39..11772ba 100644 --- a/src/model/killmail.rs +++ b/src/model/killmail.rs @@ -1,3 +1,4 @@ +/// Represents a killmail request with an ID, hash and value. #[derive(Debug, Clone)] pub struct KillmailRequest { pub id: u32, @@ -5,6 +6,9 @@ pub struct KillmailRequest { pub value: f64, } +/// Represents a single attacker in a killmail with their character ID, +/// corporation ID, alliance ID, ship ID, damage done and whether they +/// were the final blow. #[derive(Debug, Clone, Copy)] pub struct KillmailAttacker { pub character_id: u32, @@ -15,6 +19,8 @@ pub struct KillmailAttacker { pub final_blow: bool, } +/// Represents the victim of a killmail with their character ID, corporation +/// ID, alliance ID and ship ID. #[derive(Debug, Clone, Copy)] pub struct KillmailVictim { pub character_id: u32, @@ -23,6 +29,8 @@ pub struct KillmailVictim { pub ship_id: u32, } +/// Represents a killmail with the system ID of where it happened, the victim +/// and a list of attackers. #[derive(Debug, Clone)] pub struct Killmail { pub system_id: u32, diff --git a/src/model/mod.rs b/src/model/mod.rs index 1ebc8c0..a1e686a 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,5 +1,11 @@ -pub mod alliance; -pub mod character; -pub mod corporation; +mod alliance; +mod character; +mod corporation; +mod ship; + +pub use alliance::Alliance; +pub use character::Character; +pub use corporation::Corporation; +pub use ship::Ship; + pub mod killmail; -pub mod ship; diff --git a/src/model/ship.rs b/src/model/ship.rs index ae92bd2..d19fdec 100644 --- a/src/model/ship.rs +++ b/src/model/ship.rs @@ -1,8 +1,17 @@ use image::RgbImage; +/// Represents a ship in a game or simulation. +/// +/// The `Ship` struct contains information about a single ship, +/// including its unique identifier (`ship_id`), name, and icon (represented as an RGB image). #[derive(Debug, Clone)] pub struct Ship { + /// A unique identifier for the ship. pub ship_id: u32, + + /// The name of the ship. pub name: String, + + /// An icon representing the ship, stored as an RGB image. pub icon: RgbImage, } diff --git a/src/services/esi_static.rs b/src/services/esi_static.rs index 0154065..4246103 100644 --- a/src/services/esi_static.rs +++ b/src/services/esi_static.rs @@ -1,136 +1,56 @@ -use image::load_from_memory; +//! # ESI Static Client +//! +//! Provides [`EsiClient`], a cached client for retrieving data from the +//! [EVE Online ESI API](https://esi.evetech.net/). +//! +//! Features: +//! - Fetch alliance, corporation, character, ship, and system information. +//! - Transparent in-memory caching with [`SimpleCache`]. +//! - On-disk image caching with [`ImageCache`] for logos, portraits, and icons. +//! +//! Intended for applications that repeatedly request static entity data +//! (like killboards, dashboards, or analysis tools). + +use image::{RgbImage, load_from_memory}; use reqwest::blocking::Client; -use serde::Deserialize; +use serde::{Deserialize, de::DeserializeOwned}; use std::fmt; +use std::fs; +use std::path::Path; -use crate::model::alliance::Alliance; -use crate::model::character::Character; -use crate::model::corporation::Corporation; -use crate::model::ship::Ship; +use crate::cache::{SimpleCache, image::ImageCache}; +use crate::model::{Alliance, Character, Corporation, Ship}; -impl EsiClient { - pub fn new() -> Self { - let client = Client::builder() - .user_agent("killpaper-hw,oli1111@web.de") - .gzip(true) - .brotli(true) - .build() - .unwrap(); +/// Structure to parse Alliance info response from json +#[derive(Deserialize, Clone)] +struct AllianceInfo { + name: String, + ticker: String, +} - Self { - reqwest_client: client, - } - } +/// Structure to parse Corporation info response from json +#[derive(Deserialize, Clone)] +struct CorporationInfo { + name: String, + ticker: String, +} - pub fn get_alliance(&self, id: u32) -> Result { - #[derive(Deserialize)] - struct AllianceInfo { - name: String, - ticker: String, - } +/// Structure to parse Character info response from json +#[derive(Deserialize, Clone)] +struct CharacterInfo { + name: String, +} - let info_url = format!("https://esi.evetech.net/latest/alliances/{id}/"); - println!("REQ ESI{}", info_url); - let info: AllianceInfo = self.reqwest_client.get(&info_url).send()?.json()?; +/// Structure to parse Ship info response from json +#[derive(Deserialize, Clone)] +struct ShipInfo { + name: String, +} - let logo_url = format!("https://images.evetech.net/alliances/{id}/logo?size=64"); - println!("REQ ESI{}", logo_url); - - let bytes = self.reqwest_client.get(&logo_url).send()?.bytes()?; - let img = load_from_memory(&bytes)?.to_rgb8(); - - Ok(Alliance { - alliance_id: id, - name: info.name, - short: info.ticker, - logo: img, - }) - } - - pub fn get_corporation(&self, id: u32) -> Result { - #[derive(Deserialize)] - struct CorporationInfo { - name: String, - ticker: String, - } - - let info_url = format!("https://esi.evetech.net/latest/corporations/{id}/"); - println!("REQ ESI{}", info_url); - let info: CorporationInfo = self.reqwest_client.get(&info_url).send()?.json()?; - - let logo_url = format!("https://images.evetech.net/corporations/{id}/logo?size=64"); - println!("REQ ESI{}", logo_url); - let bytes = self.reqwest_client.get(&logo_url).send()?.bytes()?; - let img = load_from_memory(&bytes)?.to_rgb8(); - - Ok(Corporation { - corporation_id: id, - name: info.name, - short: info.ticker, - logo: img, - }) - } - - pub fn get_character(&self, id: u32) -> Result { - #[derive(Deserialize)] - struct CharacterInfo { - name: String, - } - - let info_url = format!("https://esi.evetech.net/latest/characters/{id}/"); - println!("REQ ESI{}", info_url); - - let info: CharacterInfo = self.reqwest_client.get(&info_url).send()?.json()?; - - let portrait_url = format!("https://images.evetech.net/characters/{id}/portrait?size=64"); - println!("REQ ESI{}", portrait_url); - - let bytes = self.reqwest_client.get(&portrait_url).send()?.bytes()?; - let img = load_from_memory(&bytes)?.to_rgb8(); - - Ok(Character { - character_id: id, - name: info.name, - portrait: img, - }) - } - - pub fn get_ship(&self, id: u32) -> Result { - #[derive(Deserialize)] - struct ShipInfo { - name: String, - } - - let info_url = format!("https://esi.evetech.net/latest/universe/types/{id}/"); - println!("REQ ESI{}", info_url); - - let info: ShipInfo = self.reqwest_client.get(&info_url).send()?.json()?; - - let icon_url = format!("https://images.evetech.net/types/{id}/icon?size=64"); - println!("REQ ESI{}", icon_url); - - let bytes = self.reqwest_client.get(&icon_url).send()?.bytes()?; - let img = load_from_memory(&bytes)?.to_rgb8(); - - Ok(Ship { - ship_id: id, - name: info.name, - icon: img, - }) - } - - pub fn get_system(&self, id: u32) -> Result { - #[derive(Deserialize)] - struct SystemInfo { - name: String, - } - - let url = format!("https://esi.evetech.net/latest/universe/systems/{id}/"); - println!("REQ ESI{}", url); - let info: SystemInfo = self.reqwest_client.get(&url).send()?.json()?; - - Ok(info.name) - } +/// Structure to parse System info response from json +#[derive(Deserialize, Clone)] +struct SystemInfo { + name: String, } #[derive(Debug)] @@ -168,8 +88,293 @@ impl From for EsiError { } impl std::error::Error for EsiError {} + +/// A client for interacting with the [EVE Online ESI API](https://esi.evetech.net/). +/// +/// `EsiClient` provides cached access to alliance, corporation, character, +/// ship, and system information, as well as associated images (logos, portraits, icons). +/// It uses both in-memory caches (`SimpleCache`) and on-disk image caches (`ImageCache`) +/// to minimize redundant API calls and improve performance. +/// +/// # Example +/// ```no_run +/// let mut esi = EsiClient::new(); +/// let alliance = esi.get_alliance(99000006).unwrap(); +/// println!("Alliance: {} [{}]", alliance.name, alliance.short); +/// ``` pub struct EsiClient { pub(crate) reqwest_client: Client, + alli_cache: SimpleCache, + corp_cache: SimpleCache, + char_cache: SimpleCache, + ship_cache: SimpleCache, + system_cache: SimpleCache, + + alli_image_cache: ImageCache, + corp_image_cache: ImageCache, + char_image_cache: ImageCache, + ship_image_cache: ImageCache, +} + +impl EsiClient { + /// Creates a new `EsiClient` instance with default configuration. + /// + /// Initializes the underlying `reqwest::Client` with compression enabled, + /// sets a custom user agent, and ensures cache directories for images exist. + /// + /// # Panics + /// Panics if cache directories cannot be created. + pub fn new() -> Self { + let client = Client::builder() + .user_agent("killpaper-hw,oli1111@web.de") + .gzip(true) + .brotli(true) + .build() + .unwrap(); + + let alli_img_path = "cache/alli"; + let corp_img_path = "cache/corp"; + let char_img_path = "cache/char"; + let ship_img_path = "cache/ship"; + + for dir in [alli_img_path, corp_img_path, char_img_path, ship_img_path] { + fs::create_dir_all(Path::new(dir)).expect("could not create cache dir"); + } + + Self { + reqwest_client: client, + alli_cache: SimpleCache::new(), + corp_cache: SimpleCache::new(), + char_cache: SimpleCache::new(), + ship_cache: SimpleCache::new(), + system_cache: SimpleCache::new(), + + alli_image_cache: ImageCache::new(alli_img_path.to_string()), + corp_image_cache: ImageCache::new(corp_img_path.to_string()), + char_image_cache: ImageCache::new(char_img_path.to_string()), + ship_image_cache: ImageCache::new(ship_img_path.to_string()), + } + } + + /// Fetches and caches a JSON resource from ESI. + /// + /// Checks the provided [`SimpleCache`] for the given `id`. + /// If not cached, it performs a GET request, deserializes the response, + /// stores it in the cache, and returns the result. + /// + /// # Errors + /// Returns [`EsiError`] if the request or deserialization fails. + fn request_with_cache( + id: u32, + cache: &mut SimpleCache, + client: &Client, + request: String, + ) -> Result + where + T: Clone + DeserializeOwned, + { + if let Some(value) = cache.lookup(id) { + //println!("VALUE hit {}", id); + Ok(value) + } else { + //println!("VALUE miss {} {}",id, request); + let info: T = client.get(&request).send()?.json()?; + cache.insert(id, &info); + Ok(info) + } + } + + /// Fetches and caches an image resource from ESI. + /// + /// Checks the provided [`ImageCache`] for the given `id`. + /// If not cached, it performs a GET request, decodes the image into `RgbImage`, + /// stores it in the cache, and returns the result. + /// + /// # Errors + /// Returns [`EsiError`] if the request, decoding, or caching fails. + fn request_image_with_cache( + id: u32, + cache: &mut ImageCache, + client: &Client, + request: String, + ) -> Result { + if let Some(value) = cache.lookup(id) { + //println!("IMG hit {}", id); + Ok(value) + } else { + //println!("IMG miss{} {}",id, request); + let bytes = client.get(&request).send()?.bytes()?; + let img = load_from_memory(&bytes)?.to_rgb8(); + cache.insert(id, &img)?; + Ok(img) + } + } + + /// Retrieves information about an alliance, including its name, ticker, and logo. + /// + /// Uses ESI's alliance information and image endpoints. + /// + /// # Arguments + /// * `id` - The alliance ID. + /// + /// # Returns + /// An [`Alliance`] object containing the alliance details. + /// + /// # Errors + /// Returns [`EsiError`] if the request or image retrieval fails. + pub fn get_alliance(&mut self, id: u32) -> Result { + let info_url = format!("https://esi.evetech.net/latest/alliances/{id}/"); + let info = EsiClient::request_with_cache( + id, + &mut self.alli_cache, + &self.reqwest_client, + info_url, + )?; + + let logo_url = format!("https://images.evetech.net/alliances/{id}/logo?size=64"); + let img = EsiClient::request_image_with_cache( + id, + &mut self.alli_image_cache, + &self.reqwest_client, + logo_url, + )?; + + Ok(Alliance { + alliance_id: id, + name: info.name, + short: info.ticker, + logo: img, + }) + } + + /// Retrieves information about a corporation, including its name, ticker, and logo. + /// + /// Uses ESI's corporation information and image endpoints. + /// + /// # Arguments + /// * `id` - The corporation ID. + /// + /// # Returns + /// A [`Corporation`] object containing the corporation details. + /// + /// # Errors + /// Returns [`EsiError`] if the request or image retrieval fails. + pub fn get_corporation(&mut self, id: u32) -> Result { + let info_url = format!("https://esi.evetech.net/latest/corporations/{id}/"); + let info = EsiClient::request_with_cache( + id, + &mut self.corp_cache, + &self.reqwest_client, + info_url, + )?; + + let logo_url = format!("https://images.evetech.net/corporations/{id}/logo?size=64"); + let img = EsiClient::request_image_with_cache( + id, + &mut self.corp_image_cache, + &self.reqwest_client, + logo_url, + )?; + + Ok(Corporation { + corporation_id: id, + name: info.name, + short: info.ticker, + logo: img, + }) + } + + /// Retrieves information about a character, including its name and portrait. + /// + /// Uses ESI's character information and image endpoints. + /// + /// # Arguments + /// * `id` - The character ID. + /// + /// # Returns + /// A [`Character`] object containing the character details. + /// + /// # Errors + /// Returns [`EsiError`] if the request or image retrieval fails. + pub fn get_character(&mut self, id: u32) -> Result { + let info_url = format!("https://esi.evetech.net/latest/characters/{id}/"); + let info = EsiClient::request_with_cache( + id, + &mut self.char_cache, + &self.reqwest_client, + info_url, + )?; + + let portrait_url = format!("https://images.evetech.net/characters/{id}/portrait?size=64"); + let img = EsiClient::request_image_with_cache( + id, + &mut self.char_image_cache, + &self.reqwest_client, + portrait_url, + )?; + + Ok(Character { + character_id: id, + name: info.name, + portrait: img, + }) + } + + /// Retrieves information about a ship type, including its name and icon. + /// + /// Uses ESI's universe type information and image endpoints. + /// + /// # Arguments + /// * `id` - The ship type ID. + /// + /// # Returns + /// A [`Ship`] object containing the ship details. + /// + /// # Errors + /// Returns [`EsiError`] if the request or image retrieval fails. + pub fn get_ship(&mut self, id: u32) -> Result { + let info_url = format!("https://esi.evetech.net/latest/universe/types/{id}/"); + let info = EsiClient::request_with_cache( + id, + &mut self.ship_cache, + &self.reqwest_client, + info_url, + )?; + + let icon_url = format!("https://images.evetech.net/types/{id}/icon?size=64"); + let img = EsiClient::request_image_with_cache( + id, + &mut self.ship_image_cache, + &self.reqwest_client, + icon_url, + )?; + + Ok(Ship { + ship_id: id, + name: info.name, + icon: img, + }) + } + + /// Retrieves the name of a solar system. + /// + /// Uses ESI's universe system endpoint. + /// + /// # Arguments + /// * `id` - The system ID. + /// + /// # Returns + /// The system name as a `String`. + /// + /// # Errors + /// Returns [`EsiError`] if the request fails. + pub fn get_system(&mut self, id: u32) -> Result { + let url = format!("https://esi.evetech.net/latest/universe/systems/{id}/"); + let info = + EsiClient::request_with_cache(id, &mut self.system_cache, &self.reqwest_client, url)?; + + Ok(info.name) + } } #[cfg(test)] @@ -178,7 +383,7 @@ mod tests { #[test] fn test_get_alliance() -> Result<(), EsiError> { - let esi = EsiClient::new(); + let mut esi = EsiClient::new(); let alliance = esi.get_alliance(99002685)?; assert_eq!(alliance.alliance_id, 99002685); @@ -194,7 +399,7 @@ mod tests { #[test] fn test_get_corporation() -> Result<(), EsiError> { - let esi = EsiClient::new(); + let mut esi = EsiClient::new(); let corp = esi.get_corporation(98330748)?; assert_eq!(corp.corporation_id, 98330748); @@ -210,7 +415,7 @@ mod tests { #[test] fn test_get_character() -> Result<(), EsiError> { - let esi = EsiClient::new(); + let mut esi = EsiClient::new(); let character = esi.get_character(90951867)?; assert_eq!(character.character_id, 90951867); @@ -230,7 +435,7 @@ mod tests { #[test] fn test_get_ship() -> Result<(), EsiError> { - let esi = EsiClient::new(); + let mut esi = EsiClient::new(); let ship = esi.get_ship(587)?; // Rifter assert_eq!(ship.ship_id, 587); @@ -245,7 +450,7 @@ mod tests { #[test] fn test_get_system() -> Result<(), EsiError> { - let esi = EsiClient::new(); + let mut esi = EsiClient::new(); let system_name = esi.get_system(30000142)?; // Jita assert_eq!(system_name, "Jita"); diff --git a/src/services/mod.rs b/src/services/mod.rs index 653d645..0b50453 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,2 +1,36 @@ -pub mod esi_static; -pub mod zkill; +//! # EVE Online API Clients +//! +//! This module provides clients for working with EVE Online data sources: +//! +//! - [`EsiClient`] → Cached access to EVE Swagger Interface (ESI) for alliances, corporations, characters, ships, and systems. +//! - [`ZkillClient`] → Fetches kills, losses, and killmail details from [zKillboard](https://zkillboard.com) and ESI. +//! - [`RedisQClient`] → Consumes real-time killmail notifications from [zKillboard's RedisQ stream](https://redisq.zkillboard.com). +//! +//! ## Example +//! ```no_run +//! use mycrate::api::{EsiClient, ZkillClient, RedisQClient}; +//! +//! // Create an ESI client +//! let mut esi = EsiClient::new(); +//! let corp = esi.get_corporation(123456789).unwrap(); +//! println!("Corporation: {} [{}]", corp.name, corp.short); +//! +//! // Create a Zkillboard client +//! let zkill = ZkillClient::new(); +//! let kills = zkill.get_corporation_kills(123456789, 3600).unwrap(); +//! +//! // Create a RedisQ client +//! let mut redisq = RedisQClient::new("mychannel".into()); +//! redisq.set_corporation_id(123456789); +//! if let Some((req, target)) = redisq.next() { +//! println!("Received killmail {} as {:?}", req.id, target); +//! } +//! ``` +mod esi_static; +mod redisq; +mod zkill; + +pub use esi_static::{EsiClient, EsiError}; +pub use redisq::RedisQClient; +pub use redisq::Target; +pub use zkill::{ZkillClient, ZkillError}; diff --git a/src/services/redisq.rs b/src/services/redisq.rs new file mode 100644 index 0000000..7f75e7f --- /dev/null +++ b/src/services/redisq.rs @@ -0,0 +1,199 @@ +//! # RedisQ Client +//! +//! Provides [`RedisQClient`], a client for consuming real-time killmail +//! notifications from zKillboard's [RedisQ](https://redisq.zkillboard.com) API. +//! +//! Features: +//! - Subscribe to a unique queue/channel for killmail delivery. +//! - Optional filtering by corporation ID (classify as Kill or Loss). +//! - Blocking long-polling interface for stream consumption. +//! +//! Useful for live dashboards, bots, or services that react to new kills +//! as they happen in EVE Online. + +use reqwest::blocking::Client; +use serde::Deserialize; + +use crate::model::killmail::*; + +/// A simplified killmail structure returned by RedisQ. +/// +/// This is a subset of the official killmail format provided by zKillboard. +/// It contains the essential information needed for filtering and processing. +#[derive(Debug, Deserialize, Clone)] +pub struct QKillmail { + #[serde(rename = "solar_system_id")] + pub _system_id: u32, + #[serde(rename = "killmail_id")] + pub killmail_id: u32, + pub victim: QKillmailVictim, + pub attackers: Vec, +} + +/// Victim details inside a killmail. +#[derive(Debug, Deserialize, Clone)] +pub struct QKillmailVictim { + pub corporation_id: u32, +} + +/// Attacker details inside a killmail. +/// +/// Some attackers may not have a corporation ID (e.g., NPCs). +#[derive(Debug, Deserialize, Clone)] +pub struct QKillmailAttacker { + pub corporation_id: Option, +} + +/// The root JSON wrapper returned by the RedisQ API. +#[derive(Debug, Deserialize)] +struct QKillmailPackage { + package: QKillmailWrapper, +} + +/// Additional metadata about a kill from zKillboard. +/// +/// Contains the hash (used to fetch the full killmail) and ISK value. +#[derive(Debug, Deserialize)] +struct QZkbInfo { + hash: String, + #[serde(rename = "totalValue")] + total_value: f64, +} + +/// Internal wrapper that groups killmail data with zKillboard info. +#[derive(Debug, Deserialize)] +struct QKillmailWrapper { + killmail: QKillmail, + zkb: QZkbInfo, +} + +pub struct RedisQClient { + client: Client, + filter_corp_id: Option, + channel: String, +} + +/// Represents whether a retrieved killmail is a kill or a loss +/// relative to the configured corporation filter. +pub enum Target { + /// A kill where the configured corporation was one of the attackers. + Kill, + /// A loss where the configured corporation was the victim. + Loss, +} + +/// Client for consuming killmails from [zKillboard's RedisQ stream](https://redisq.zkillboard.com/listen.php). +/// +/// `RedisQClient` polls the zKillboard RedisQ endpoint and retrieves killmail +/// notifications in near real-time. You can optionally filter by corporation ID +/// to separate kills from losses. +/// +/// # Example +/// ```no_run +/// let mut client = RedisQClient::new("example_channel".into()); +/// client.set_corporation_id(123456789); +/// +/// while let Some((km, target)) = client.next() { +/// match target { +/// Target::Kill => println!("We scored a kill worth {} ISK!", km.value), +/// Target::Loss => println!("We suffered a loss: {}", km.id), +/// } +/// } +/// ``` +impl RedisQClient { + /// Creates a new [`RedisQClient`] with the given channel name. + /// + /// # Arguments + /// * `channel` - A RedisQ queue identifier. Each client should have its own unique channel. + pub fn new(channel: String) -> Self { + let client = Client::builder() + .user_agent("killpaper-hw,oli1111@web.de") + .gzip(true) + .brotli(true) + .build() + .unwrap(); + + RedisQClient { + client, + filter_corp_id: None, + channel, + } + } + + /// Sets the corporation ID to filter killmails against. + /// + /// When set: + /// - If the corporation appears among the attackers, the killmail is categorized as [`Target::Kill`]. + /// - If the corporation is the victim, it is categorized as [`Target::Loss`]. + /// - If neither, the killmail is ignored. + /// + /// When Not set + /// - All killmails are reported ass kills + pub fn set_corporation_id(&mut self, id: u32) { + self.filter_corp_id = Some(id) + } + + /// Retrieves the next killmail from the RedisQ stream. + /// + /// This method performs a blocking HTTP request to the RedisQ endpoint, + /// deserializes the response, applies the corporation filter (if set), + /// and returns a tuple of: + /// - [`KillmailRequest`] (containing ID, hash, and ISK value) + /// - [`Target`] (Kill or Loss) + /// + /// # Returns + /// * `Some((KillmailRequest, Target))` if a valid killmail was retrieved and passed the filter. + /// * `None` if no valid killmail is available or the filter excluded it. + /// + /// # Notes + /// - RedisQ is a long-polling API; this call may block until a killmail is available. + /// - Malformed or unexpected responses are logged to stdout and skipped. + pub fn next(&self) -> Option<(KillmailRequest, Target)> { + let url = format!( + "https://zkillredisq.stream/listen.php?queueID={}", + self.channel + ); + + if let Ok(resp) = self.client.get(&url).send() { + // Read the response as a string first + if let Ok(text) = resp.text() { + // Deserialize from the same string + match serde_json::from_str::(&text) { + Ok(mail) => { + let km = mail.package.killmail.clone(); + + let target = if let Some(filter_id) = self.filter_corp_id { + if km + .attackers + .iter() + .any(|a| a.corporation_id == Some(filter_id)) + { + Target::Kill + } else if km.victim.corporation_id == filter_id { + Target::Loss + } else { + // Corp_id filter set but no match + return None; + } + } else { + Target::Kill + }; + + let request = KillmailRequest { + id: km.killmail_id, + hash: mail.package.zkb.hash, + value: mail.package.zkb.total_value, + }; + + return Some((request, target)); + } + Err(err) => { + println!("REDISQ_Error: {:?}", err); + } + } + } + } + + None + } +} diff --git a/src/services/zkill.rs b/src/services/zkill.rs index f712d07..c5875a7 100644 --- a/src/services/zkill.rs +++ b/src/services/zkill.rs @@ -1,26 +1,46 @@ +//! # zKillboard Client +//! +//! Provides [`ZkillClient`], a client for querying [zKillboard](https://zkillboard.com) +//! and retrieving killmails, kills, and losses. +//! +//! Features: +//! - Fetch corporation kills or losses within a configurable time window. +//! - Retrieve full killmail details (attackers, victim, system) via ESI. +//! - Unified error handling through [`ZkillError`]. +//! +//! Useful for historical data queries, killmail processing pipelines, +//! or tools that need aggregated kill/loss information. + use reqwest::blocking::Client; use serde::Deserialize; use std::fmt; use crate::model::killmail::*; +/// API structure to parse zKillboard's kill response JSON. +/// +/// Returned when querying kills or losses from the zKillboard API. #[derive(Debug, Deserialize)] struct ZkbKill { + /// The killmail ID. killmail_id: u32, + /// Metadata about the kill from zKillboard. zkb: ZkbInfo, } +/// Metadata returned by zKillboard for a killmail. #[derive(Debug, Deserialize)] struct ZkbInfo { + /// Hash required to retrieve the full killmail via ESI. hash: String, - totalValue: f64, + /// Total ISK value of the kill. + #[serde(rename = "totalValue")] + total_value: f64, } -pub struct ZkillClient { - client: Client, -} - -// ESI API deserialization structs +/// API structure representing an attacker inside an ESI killmail. +/// +/// Values are optional since some attackers may lack certain fields (e.g., NPCs). #[derive(Deserialize)] struct EsiAttacker { character_id: Option, @@ -31,6 +51,9 @@ struct EsiAttacker { final_blow: Option, } +/// API structure representing the victim inside an ESI killmail. +/// +/// Values are optional since not all fields are guaranteed to be present. #[derive(Deserialize)] struct EsiVictim { character_id: Option, @@ -39,18 +62,28 @@ struct EsiVictim { ship_type_id: Option, } +/// API structure representing the core ESI killmail response. +/// +/// Used to retrieve detailed killmail information from ESI +/// after resolving it via zKillboard. #[derive(Deserialize)] struct EsiKillmail { + /// The solar system where the kill occurred. solar_system_id: u32, + /// List of attackers. attackers: Vec, + /// The victim. victim: EsiVictim, } -// Custom error type +/// Error type for zKillboard and ESI API operations. #[derive(Debug)] pub enum ZkillError { + /// Wrapper for `reqwest` errors during HTTP requests. Reqwest(reqwest::Error), + /// Wrapper for `serde_json` errors during deserialization. Json(serde_json::Error), + /// Returned when an entity (e.g., killmail) was not found. NotFound(String), } @@ -78,7 +111,30 @@ impl From for ZkillError { } } +/// A client for interacting with [zKillboard](https://zkillboard.com) and +/// fetching killmail information from both zKillboard and EVE ESI. +/// +/// `ZkillClient` can: +/// - Retrieve corporation kills or losses within a given time window. +/// - Fetch full killmail details (attackers, victim, system) via ESI. +/// +/// # Example +/// ```no_run +/// let client = ZkillClient::new(); +/// let kills = client.get_corporation_kills(123456789, 3600).unwrap(); +/// for km in kills { +/// let details = client.fetch_killmail_details(&km).unwrap(); +/// println!("Kill in system {} worth {} ISK", details.system_id, km.value); +/// } +/// ``` +pub struct ZkillClient { + client: Client, +} + impl ZkillClient { + /// Creates a new [`ZkillClient`] with a configured `reqwest::Client`. + /// + /// The client has compression enabled and a custom user agent set. pub fn new() -> Self { let client = Client::builder() .user_agent("killpaper-hw,oli1111@web.de") @@ -90,6 +146,17 @@ impl ZkillClient { ZkillClient { client } } + /// Fetches kills for a corporation within a given time window. + /// + /// # Arguments + /// * `corp_id` - The corporation ID to fetch kills for. + /// * `past_seconds` - Look-back window in seconds. + /// + /// # Returns + /// A vector of [`KillmailRequest`] containing killmail IDs, hashes, and values. + /// + /// # Errors + /// Returns [`ZkillError`] if the request or JSON parsing fails. pub fn get_corporation_kills( &self, corp_id: u32, @@ -108,7 +175,7 @@ impl ZkillClient { .map(|k| KillmailRequest { id: k.killmail_id, hash: k.zkb.hash, - value: k.zkb.totalValue, + value: k.zkb.total_value, }) .collect(); @@ -133,7 +200,7 @@ impl ZkillClient { .map(|k| KillmailRequest { id: k.killmail_id, hash: k.zkb.hash, - value: k.zkb.totalValue, + value: k.zkb.total_value, }) .collect();