From f937aeca258feec8515a98f6b99765683debd974 Mon Sep 17 00:00:00 2001 From: Oliver Walter Date: Fri, 5 Sep 2025 00:47:46 +0200 Subject: [PATCH] release 1.0 --- .gitignore | 3 + .vscode/settings.json | 6 - Cargo.lock | 151 +++++++- Cargo.toml | 10 +- killmails_display.png | Bin 3297 -> 0 bytes output.png | Bin 54522 -> 0 bytes readme.md | 93 +++++ src/app.rs | 213 +++++++++++ src/cache/image.rs | 84 ++++ src/cache/mod.rs | 36 ++ src/{ => display}/epaper.rs | 27 +- src/{display.rs => display/image_sim.rs} | 175 ++++++++- src/display/mod.rs | 54 +++ src/display/simulator.rs | 379 ++++++++++++++++++ src/epd/color.rs | 22 -- src/epd/epd7in5_v2/mod.rs | 1 - src/killinfo.rs | 68 +++- src/lib.rs | 3 + src/main.rs | 90 ++--- src/model/alliance.rs | 5 + src/model/character.rs | 6 + src/model/corporation.rs | 8 + src/model/killmail.rs | 8 + src/model/mod.rs | 14 +- src/model/ship.rs | 9 + src/services/esi_static.rs | 465 ++++++++++++++++------- src/services/mod.rs | 38 +- src/services/redisq.rs | 199 ++++++++++ src/services/zkill.rs | 85 ++++- 29 files changed, 1984 insertions(+), 268 deletions(-) delete mode 100644 .vscode/settings.json delete mode 100644 killmails_display.png delete mode 100644 output.png create mode 100644 readme.md create mode 100644 src/app.rs create mode 100644 src/cache/image.rs create mode 100644 src/cache/mod.rs rename src/{ => display}/epaper.rs (94%) rename src/{display.rs => display/image_sim.rs} (62%) create mode 100644 src/display/mod.rs create mode 100644 src/display/simulator.rs create mode 100644 src/services/redisq.rs 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 5a29405f88b937de3f2f65f76047ce777c6a83f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3297 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV0^#<6kw>x3Daa?;9246;uuoF`1Szv0+q;V z$3Ffqf7g5B%<>}d6&%9|~>*pC6E4}+!wmqwAS-W-i)%8VJL&Ntfgsj{3 zYL$4)snGD)uT23vU#(glC!iG`TY8n->FKJ~>#7`A#FSnQ)#sSH8mN$U;i#t3fEY~? zqgi9Lj2JB}M{AAI>T|S7Fxoa6Z6uDimhpER9?CPmS;yXJ8r8ED)OYc8^>bP0l+XkK D=can+ diff --git a/output.png b/output.png deleted file mode 100644 index 8491aaffc36304e4d55d562d4582303865319607..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54522 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 M6{E|A7wsPXU(~eu { + 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();