release 1.0

This commit is contained in:
2025-09-05 00:47:46 +02:00
parent 89689c44fd
commit f937aeca25
29 changed files with 1984 additions and 268 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
/target
/cache
*.png
.vscode

View File

@@ -1,6 +0,0 @@
{
"cSpell.words": [
"Killmail",
"killpaper"
]
}

151
Cargo.lock generated
View File

@@ -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"

View File

@@ -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 = []

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

93
readme.md Normal file
View File

@@ -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`).

213
src/app.rs Normal file
View File

@@ -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<D> {
esi: EsiClient,
zkill: ZkillClient,
redisq: RedisQClient,
killbuilder: killinfo::KillInfoBuilder,
display: D,
corp_id: u32,
kills: Vec<KillInfo>,
losses: Vec<KillInfo>,
}
/// Number of seconds in a day, used for initial historical queries.
const SECONDS_A_DAY: u32 = 60 * 60 * 24;
impl<D> App<D>
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<KillmailRequest> {
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<KillmailRequest> {
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<KillInfo> {
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<KillInfo> {
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 its a kill, it is prepended to the kills list.
/// - If its 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;
}
}

84
src/cache/image.rs vendored Normal file
View File

@@ -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<u32>,
}
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::<u32>() {
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<RgbImage> {
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(())
}
}

36
src/cache/mod.rs vendored Normal file
View File

@@ -0,0 +1,36 @@
use std::collections::BTreeMap;
pub mod image;
///
/// A simple cache that stores items with a unique ID.
///
pub struct SimpleCache<T: Clone> {
collection: BTreeMap<u32, T>,
}
impl<T: Clone> SimpleCache<T> {
/// 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<T> {
self.collection.get(&id).cloned()
}
}

View File

@@ -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

View File

@@ -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<BinaryColor, RawU1, BigEndian, WIDTH, HEIGHT, { WIDTH * HEIGHT }>,
}
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<u8> = 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::<BinaryColor>::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::<BinaryColor>::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::<BinaryColor>::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::<BinaryColor>::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<u8> {

54
src/display/mod.rs Normal file
View File

@@ -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;

379
src/display/simulator.rs Normal file
View File

@@ -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<Gray8>,
window: Window,
}
impl Simulator {
pub fn new() -> Self {
let display = SimulatorDisplay::<Gray8>::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::<Gray8>::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::<Gray8>::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::<Gray8>::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::<Gray8>::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::<Gray8>::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::<Gray8>::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::<Gray8>::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::<Gray8>::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<u8> {
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;
}

View File

@@ -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<BinaryColor> for OctColor {
fn from(b: BinaryColor) -> OctColor {
match b {
@@ -143,7 +140,6 @@ impl From<BinaryColor> for OctColor {
}
}
#[cfg(feature = "graphics")]
impl From<OctColor> for embedded_graphics::pixelcolor::Rgb888 {
fn from(b: OctColor) -> Self {
let (r, g, b) = b.rgb();
@@ -151,7 +147,6 @@ impl From<OctColor> for embedded_graphics::pixelcolor::Rgb888 {
}
}
#[cfg(feature = "graphics")]
impl From<embedded_graphics::pixelcolor::Rgb888> for OctColor {
fn from(p: embedded_graphics::pixelcolor::Rgb888) -> OctColor {
use embedded_graphics::prelude::RgbColor;
@@ -186,7 +181,6 @@ impl From<embedded_graphics::pixelcolor::Rgb888> for OctColor {
}
}
#[cfg(feature = "graphics")]
impl From<embedded_graphics::pixelcolor::raw::RawU4> for OctColor {
fn from(b: embedded_graphics::pixelcolor::raw::RawU4) -> Self {
use embedded_graphics::prelude::RawData;
@@ -194,7 +188,6 @@ impl From<embedded_graphics::pixelcolor::raw::RawU4> for OctColor {
}
}
#[cfg(feature = "graphics")]
impl PixelColor for OctColor {
type Raw = embedded_graphics::pixelcolor::raw::RawU4;
}
@@ -291,7 +284,6 @@ impl From<u8> for Color {
}
}
#[cfg(feature = "graphics")]
impl From<embedded_graphics::pixelcolor::raw::RawU1> for Color {
fn from(b: embedded_graphics::pixelcolor::raw::RawU1) -> Self {
use embedded_graphics::prelude::RawData;
@@ -303,19 +295,16 @@ impl From<embedded_graphics::pixelcolor::raw::RawU1> for Color {
}
}
#[cfg(feature = "graphics")]
impl From<Color> 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<BinaryColor> for Color {
fn from(b: BinaryColor) -> Color {
match b {
@@ -325,7 +314,6 @@ impl From<BinaryColor> for Color {
}
}
#[cfg(feature = "graphics")]
impl From<embedded_graphics::pixelcolor::Rgb888> for Color {
fn from(rgb: embedded_graphics::pixelcolor::Rgb888) -> Self {
use embedded_graphics::pixelcolor::RgbColor;
@@ -344,7 +332,6 @@ impl From<embedded_graphics::pixelcolor::Rgb888> for Color {
}
}
#[cfg(feature = "graphics")]
impl From<Color> for embedded_graphics::pixelcolor::Rgb888 {
fn from(color: Color) -> Self {
use embedded_graphics::pixelcolor::RgbColor;
@@ -355,7 +342,6 @@ impl From<Color> for embedded_graphics::pixelcolor::Rgb888 {
}
}
#[cfg(feature = "graphics")]
impl From<embedded_graphics::pixelcolor::Rgb565> for Color {
fn from(rgb: embedded_graphics::pixelcolor::Rgb565) -> Self {
use embedded_graphics::pixelcolor::RgbColor;
@@ -374,7 +360,6 @@ impl From<embedded_graphics::pixelcolor::Rgb565> for Color {
}
}
#[cfg(feature = "graphics")]
impl From<Color> for embedded_graphics::pixelcolor::Rgb565 {
fn from(color: Color) -> Self {
use embedded_graphics::pixelcolor::RgbColor;
@@ -385,7 +370,6 @@ impl From<Color> for embedded_graphics::pixelcolor::Rgb565 {
}
}
#[cfg(feature = "graphics")]
impl From<embedded_graphics::pixelcolor::Rgb555> for Color {
fn from(rgb: embedded_graphics::pixelcolor::Rgb555) -> Self {
use embedded_graphics::pixelcolor::RgbColor;
@@ -404,7 +388,6 @@ impl From<embedded_graphics::pixelcolor::Rgb555> for Color {
}
}
#[cfg(feature = "graphics")]
impl From<Color> 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<embedded_graphics::pixelcolor::raw::RawU2> for TriColor {
fn from(b: embedded_graphics::pixelcolor::raw::RawU2) -> Self {
use embedded_graphics::prelude::RawData;
@@ -447,12 +429,10 @@ impl From<embedded_graphics::pixelcolor::raw::RawU2> for TriColor {
}
}
#[cfg(feature = "graphics")]
impl PixelColor for TriColor {
type Raw = embedded_graphics::pixelcolor::raw::RawU2;
}
#[cfg(feature = "graphics")]
impl From<BinaryColor> for TriColor {
fn from(b: BinaryColor) -> TriColor {
match b {
@@ -461,7 +441,6 @@ impl From<BinaryColor> for TriColor {
}
}
}
#[cfg(feature = "graphics")]
impl From<embedded_graphics::pixelcolor::Rgb888> for TriColor {
fn from(rgb: embedded_graphics::pixelcolor::Rgb888) -> Self {
use embedded_graphics::pixelcolor::RgbColor;
@@ -475,7 +454,6 @@ impl From<embedded_graphics::pixelcolor::Rgb888> for TriColor {
}
}
}
#[cfg(feature = "graphics")]
impl From<TriColor> for embedded_graphics::pixelcolor::Rgb888 {
fn from(tri_color: TriColor) -> Self {
use embedded_graphics::pixelcolor::RgbColor;

View File

@@ -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,

View File

@@ -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<Character>,
/// Optional alliance info (may be None if unavailable)
pub alliance: Option<Alliance>,
/// The corporation of the participant (always available)
pub corporation: Corporation,
/// Optional ship info (may be None if unavailable)
pub ship: Option<Ship>,
}
/// 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<Individual>,
/// 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<KillInfo, ZkillError>` containing the fully resolved kill information.
pub fn make_kill_info(
&self,
client: &ZkillClient,
esi: &EsiClient,
esi: &mut EsiClient,
request: &KillmailRequest,
) -> Result<KillInfo, ZkillError> {
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<KillmailAttacker>,
@@ -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<Individual, EsiError>` containing the constructed participant.
pub fn build_with_esi(
client: &EsiClient,
client: &mut EsiClient,
char_id: u32,
corp_id: u32,
alli_id: u32,

View File

@@ -1,3 +1,6 @@
pub mod app;
pub mod cache;
pub mod display;
pub mod epd;
pub mod killinfo;
pub mod model;

View File

@@ -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<KillInfo> = 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<KillInfo> = 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");
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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<Alliance, EsiError> {
#[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<Corporation, EsiError> {
#[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<Character, EsiError> {
#[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<Ship, EsiError> {
#[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<String, EsiError> {
#[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<serde_json::Error> 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<AllianceInfo>,
corp_cache: SimpleCache<CorporationInfo>,
char_cache: SimpleCache<CharacterInfo>,
ship_cache: SimpleCache<ShipInfo>,
system_cache: SimpleCache<SystemInfo>,
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<T>(
id: u32,
cache: &mut SimpleCache<T>,
client: &Client,
request: String,
) -> Result<T, EsiError>
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<RgbImage, EsiError> {
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<Alliance, EsiError> {
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<Corporation, EsiError> {
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<Character, EsiError> {
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<Ship, EsiError> {
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<String, EsiError> {
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");

View File

@@ -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};

199
src/services/redisq.rs Normal file
View File

@@ -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<QKillmailAttacker>,
}
/// 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<u32>,
}
/// 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<u32>,
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::<QKillmailPackage>(&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
}
}

View File

@@ -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<u32>,
@@ -31,6 +51,9 @@ struct EsiAttacker {
final_blow: Option<bool>,
}
/// 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<u32>,
@@ -39,18 +62,28 @@ struct EsiVictim {
ship_type_id: Option<u32>,
}
/// 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<EsiAttacker>,
/// 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<serde_json::Error> 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();