Compare commits

...

16 Commits

Author SHA1 Message Date
devdesk
de88f4a856 add another huge packet capture 2025-12-16 22:28:39 +02:00
devdesk
9c9eb81437 add huge bootLoginPlay.pcapng 2025-12-16 22:28:06 +02:00
devdesk
9d6254b478 add example gst.py 2025-12-16 22:24:12 +02:00
devdesk
9e8cc5d575 add .python-version 2025-12-16 22:04:25 +02:00
devdesk
ea5b3c6860 add missing uv.lock 2025-12-16 21:59:24 +02:00
devdesk
36efc0da0a add missing src/main.rs 2025-12-16 21:59:17 +02:00
devdesk
cddd5317c3 use uv for start.sh 2025-11-11 22:40:06 +02:00
devdesk
b1a7baae19 bug fix 2024-02-23 02:12:30 +02:00
devdesk
c993f74511 hertz inverted 2024-02-23 02:10:49 +02:00
devdesk
39551f3794 web can update pulse 2024-02-23 01:54:56 +02:00
devdesk
bfaa0eca2f pulse threshold 2024-02-23 01:48:11 +02:00
devdesk
373b17d8a5 web interface 2024-02-23 01:38:32 +02:00
devdesk
ca742aa204 add hsv to rgb, not better looking 2024-02-22 23:08:30 +02:00
devdesk
831322e44a initial example axum, streamer runs in thread 2024-02-20 23:29:33 +02:00
devdesk
24b65a8ee5 turn into an executable instead of a library
burns the python library to the ground though. but I'm just doing
a web service now, so it would be simpler to just use that,
and for live python has the implementation as well still
2024-02-20 21:23:26 +02:00
devdesk
005292adfc tweaking the color 2024-02-20 21:02:14 +02:00
15 changed files with 1254 additions and 39 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
**/*.png
target
.env
MTAM1.3.0-20200525WAI-H*

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

904
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,22 +5,27 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "thermaldecoder"
crate-type = ["rlib", "cdylib"]
[dependencies]
anyhow = "1.0.77"
axum = "0.7.4"
bracket-color = "0.8.7"
clap = { version = "4.5.1", features = ["derive"] }
crossbeam = "0.8.4"
crossbeam-channel = "0.5.11"
crossterm = { version = "0.27.0", features = ["event-stream"] }
dotenv = "0.15.0"
eframe = "0.26.2"
egui = "0.26.2"
futures = "0.3.30"
futures-timer = "3.0.3"
indicatif = "0.17.7"
pcap = { version = "1.2.0", features = ["capture-stream"] }
pcap-parser = { version = "0.14.1", features = ["data"] }
png = "0.17.10"
pyo3 = { version = "0.20.0", "features" = ["extension-module"] }
reqwest = { version = "0.11.24", features = ["json"] }
serde = { version = "1.0.193", features = ["derive", "serde_derive", "alloc"] }
tokio = { version = "1.36.0", features = ["full"] }
tracing-subscriber = "0.3.18"
tui-textarea = "0.4.0"
v4l = { version = "0.14.0", features = ["v4l2"], default-features = false }

BIN
bootLoginPlay.pcapng Normal file

Binary file not shown.

111
examples/cutoff.rs Normal file
View File

@@ -0,0 +1,111 @@
use std::{collections::HashMap, io::stdout, time::Duration};
use futures::{future::FutureExt, select, StreamExt};
use futures_timer::Delay;
use crossterm::{
cursor::position,
event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent},
execute,
terminal::{disable_raw_mode, enable_raw_mode},
};
async fn set_cutoff(cutoff: f64, freq: f64) -> Result<(), reqwest::Error> {
// Some simple CLI args requirements...
let url = format!("http://localhost:3000/cutoff");
let mut map = HashMap::new();
map.insert("min_cutoff", cutoff);
map.insert("max_cutoff", cutoff + 10.0);
map.insert("freq_hz", freq);
let client = reqwest::Client::new();
let res = client.post(url).json(&map).send().await?;
// eprintln!("Response: {:?} {}", res.version(), res.status());
// eprintln!("Headers: {:#?}\n", res.headers());
// let body = res.text().await?;
// println!("{body}");
Ok(())
}
const HELP: &str = r#"EventStream based on futures_util::Stream with tokio
- Keyboard, mouse and terminal resize events enabled
- Prints "." every second if there's no event
- Hit "c" to print current cursor position
- Use Esc to quit
"#;
async fn print_events() {
let mut reader = EventStream::new();
let mut cutoff = 30.0;
let mut last_cutoff = cutoff;
let mut freq = 1.0;
loop {
let mut delay = Delay::new(Duration::from_millis(1_000)).fuse();
let mut event = reader.next().fuse();
let mut change = false;
select! {
_ = delay => {
},
maybe_event = event => {
match maybe_event {
Some(Ok(event)) => {
if event == Event::Key(KeyCode::Char('c').into()) {
println!("Cursor position: {:?}\r", position());
}
if event == Event::Key(KeyCode::Esc.into()) {
break;
}
if let Event::Key(k) = event {
if let KeyCode::Char(c) = k.code {
change = true;
match c {
'[' => {
cutoff -= 1.0;
}
']' => {
cutoff += 1.0;
}
'1' => {
freq *= 0.9;
}
'2' => {
freq *= 1.1;
}
_ => {
change = false;
}
}
}
}
if change {
set_cutoff(cutoff, freq).await.unwrap();
println!("cutoff = {}\r", cutoff);
}
}
Some(Err(e)) => println!("Error: {:?}\r", e),
None => break,
}
}
};
}
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
println!("{}", HELP);
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnableMouseCapture)?;
print_events().await;
execute!(stdout, DisableMouseCapture)?;
disable_raw_mode()
}

49
gst.py Normal file
View File

@@ -0,0 +1,49 @@
import gi
gi.require_version('Gst', '1.0')
from gi.repository import Gst, GLib
# Initialize GStreamer
Gst.init(None)
# Define the pipeline
pipeline = Gst.parse_launch(
"v4l2src device=/dev/video0 ! "
"video/x-raw, format=GRAY16_LE, width=358, height=288, framerate=30/1 ! "
"videoconvert ! "
"x264enc bitrate=500 speed-preset=ultrafast tune=zerolatency ! "
"tee name=t "
"t. ! queue ! "
"hlsdemux ! "
"hlssink playlist-root=http://example.com/live location=/var/www/html/live/segment%05d.ts playlist-location=/var/www/html/live/playlist.m3u8 "
"t. ! queue ! "
"filesink location=/path/to/save/output.h264"
)
# Start playing
pipeline.set_state(Gst.State.PLAYING)
# Wait until error or EOS
bus = pipeline.get_bus()
# Error handling
def on_message(bus, message, loop):
mtype = message.type
if mtype == Gst.MessageType.ERROR:
err, debug = message.parse_error()
print("Error: %s" % err, debug)
loop.quit()
elif mtype == Gst.MessageType.EOS:
print("End of stream")
loop.quit()
loop = GLib.MainLoop()
bus.add_signal_watch()
bus.connect("message", on_message, loop)
try:
loop.run()
except KeyboardInterrupt:
pass
# Free resources
pipeline.set_state(Gst.State.NULL)

BIN
indesk.pcapng Normal file

Binary file not shown.

10
pyproject.toml Normal file
View File

@@ -0,0 +1,10 @@
[project]
name = "thermalcam"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"python-dotenv>=1.2.1",
"scapy>=2.6.1",
]

View File

@@ -5,8 +5,8 @@ set -e
# Python works but stutters
#sudo ./venv/bin/python ./decode.py --live
cargo build --release --example live
TARGET=./target/release/examples/live
cargo build --release
TARGET=./target/release/thermaldecoder
# setcap does not work yet (EPERM on socket AF_PACKET)
# sudo setcap cap_net_raw,cap_net_admin=eip $TARGET
#sudo strace -f -o live.strace $TARGET /dev/video0

71
src/main.rs Normal file
View File

@@ -0,0 +1,71 @@
pub(crate) mod offline;
pub(crate) mod stream;
use std::sync::{Arc, Mutex};
use stream::{initialize, start_stream_thread, Streamer};
use axum::{
extract::State,
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone)]
struct AppState {
streamer: Arc<Mutex<Streamer>>,
}
#[tokio::main]
async fn main() {
// initialize tracing
tracing_subscriber::fmt::init();
println!("creating streamer");
let streamer = initialize();
println!("creating streamer thread");
start_stream_thread(streamer.clone());
let state = AppState { streamer };
println!("starting web interface");
// build our application with a route
let app = Router::new()
// `GET /` goes to `root`
.route("/", get(root))
// `POST /users` goes to `set_cutoff`
.route("/cutoff", post(set_cutoff))
.with_state(state);
// run our app with hyper, listening globally on port 3000
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
// basic handler that responds with a static string
async fn root() -> &'static str {
"Hello, TAMI!"
}
async fn set_cutoff(
State(state): State<AppState>,
// this argument tells axum to parse the request body
// as JSON into a `CreateUser` type
Json(payload): Json<SetCutoff>,
) -> (StatusCode, Json<bool>) {
let mut streamer = state.streamer.lock().unwrap();
streamer.min_cutoff = payload.min;
streamer.max_cutoff = payload.max;
streamer.freq_hz = payload.hz;
println!("updated to {:?}", payload);
(StatusCode::OK, true.into())
}
// the input to our `create_user` handler
#[derive(Deserialize, Debug)]
struct SetCutoff {
min: f64,
max: f64,
hz: f64,
}

View File

@@ -184,6 +184,7 @@ impl Iterator for Decoder {
}
}
#[allow(dead_code)]
pub fn write_raw_frame(name: &str, data: &[u8]) -> anyhow::Result<()> {
let path = Path::new(&name);
let file = File::create(path)?;
@@ -196,6 +197,7 @@ pub fn write_raw_frame(name: &str, data: &[u8]) -> anyhow::Result<()> {
Ok(())
}
#[allow(dead_code)]
fn write_calibrated_frame(name: &str, data: &[u16]) -> anyhow::Result<()> {
let path = Path::new(&name);
let file = File::create(path).unwrap();
@@ -252,6 +254,7 @@ fn decode(filename: &str) -> PyResult<PyFrameIterator> {
}
/// writes to frames/<basename of filename>
#[allow(dead_code)]
pub fn decode_to_files(filename: &str) -> anyhow::Result<()> {
let frameiter = Decoder::new(filename)?;
let basename = std::path::Path::new(filename)

View File

@@ -1,7 +1,13 @@
use crate::offline::{Header, HDR_SIZE};
use bracket_color::prelude::*;
use clap::Parser;
use dotenv::dotenv;
use std::io::Write;
use thermaldecoder::{Header, HDR_SIZE};
use std::time::SystemTime;
use std::{
io::Write,
sync::{Arc, Mutex},
thread::spawn,
};
use v4l::video::Output;
#[derive(Parser, Debug)]
@@ -26,9 +32,44 @@ fn pixel_to_celcius(x: u16) -> u16 {
(t * 256.0) as u16
}
fn main() -> anyhow::Result<()> {
/// https://en.wikipedia.org/wiki/HSL_and_HSV
/// convert to the expected dynamic range first. We insert values in [0..256)
/// h in [0, 360] degrees
/// s in [0, 1]
/// v in [0, 1]
fn once_upon_a_time_hsv2rgb(h: u8, s: u8, v: u8) -> (u8, u8, u8) {
let h = (h as f64) / 256.0 * 360.0;
let s = (s as f64) / 256.0;
let v = (v as f64) / 256.0;
(0, 0, 0)
}
fn rgb_to_u8s(rgb: &RGB) -> (u8, u8, u8) {
(
(rgb.r * 256.) as u8,
(rgb.g * 256.) as u8,
(rgb.b * 256.) as u8,
)
}
pub(crate) struct Streamer {
pub(crate) min_cutoff: f64,
pub(crate) max_cutoff: f64,
pub(crate) freq_hz: f64,
}
pub(crate) fn initialize() -> Arc<Mutex<Streamer>> {
let args = Args::parse();
Arc::new(Mutex::new(Streamer {
min_cutoff: args.red_cutoff.unwrap_or(26.),
max_cutoff: args.red_cutoff.unwrap_or(26.) + 10.0,
freq_hz: 1.0,
}))
}
fn main(streamer: Arc<Mutex<Streamer>>) -> anyhow::Result<()> {
dotenv().ok();
let args = Args::parse();
let device = match std::env::var("THERMALCAM_IFACE=enp1s0f0") {
Ok(d) => {
let device = pcap::Device::list()
@@ -50,7 +91,12 @@ fn main() -> anyhow::Result<()> {
const WIDTH: usize = 288;
const HEIGHT: usize = 384;
let greyscale = !args.temperature || args.red_cutoff.is_none();
println!("reading cutoff");
let start = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let greyscale = !args.temperature;
let fourcc_repr = if greyscale {
[
b'Y', // | 0b10000000
@@ -62,6 +108,7 @@ fn main() -> anyhow::Result<()> {
// RGB32 is 4 bytes R, G, B, A
[b'R', b'G', b'B', b'4']
};
println!("using four cc {:?}", fourcc_repr);
let bytes_per_pixel = if greyscale { 2 } else { 4 };
let fourcc = v4l::format::FourCC { repr: fourcc_repr };
let mut out = v4l::Device::with_path(output)?;
@@ -103,6 +150,17 @@ fn main() -> anyhow::Result<()> {
|| (data.len() + len > FRAME_LEN)
{
if len == FRAME_LEN {
// read once per frame, can make it lower if need be
let state = streamer.lock().unwrap();
let mid = (state.min_cutoff + state.max_cutoff) / 2.0;
let range = state.max_cutoff - state.min_cutoff;
let hz = state.freq_hz;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let dt = now - start;
let cutoff = mid + f64::sin(dt * hz) * 0.5 * range;
// swap the bytes, we are using LE, not BE, 16 bit grayscale
// possibly limitation of current v4l2loopback or v4l rust wrapper or libv4l2
for i in 0..FRAME_LEN / 2 {
@@ -118,14 +176,16 @@ fn main() -> anyhow::Result<()> {
swapped[out_i..out_i + 2].copy_from_slice(&pixel_swapped);
} else {
pixel = pixel_to_celcius(pixel);
let cutoff = args.red_cutoff.unwrap();
let r = if pixel > (256.0 * cutoff) as u16 {
255
let (r, g, b) = if pixel > (256.0 * cutoff) as u16 {
let p = pixel - (256.0 * cutoff) as u16;
let rgb = HSV::from_f32(0.0, (p as f32) / 256.0, 0.0).to_rgb();
rgb_to_u8s(&rgb)
} else {
0
let rgb =
HSV::from_f32(pixel as f32 / 65536.0, 0.0, pixel as f32 / 65536.0)
.to_rgb();
rgb_to_u8s(&rgb)
};
let g = frame[i * 2];
let b = frame[i * 2 + 1];
let out_i = ((HEIGHT - 1 - y) + (WIDTH - 1 - x) * HEIGHT) * 4;
swapped[out_i..out_i + 4].copy_from_slice(&[0, r, g, b]);
}
@@ -139,3 +199,11 @@ fn main() -> anyhow::Result<()> {
}
Ok(())
}
pub(crate) fn start_stream_thread(streamer: Arc<Mutex<Streamer>>) {
spawn(move || {
if let Err(e) = main(streamer) {
println!("oops: {:?}", e);
}
});
}

View File

@@ -9,4 +9,4 @@ if [ $mtu -lt 9000 ]; then
echo "setting mtu to 9000"
sudo ip link set $IFACE mtu 9000
fi
sudo venv/bin/python ./replay.py
sudo uv run ./replay.py

36
uv.lock generated Normal file
View File

@@ -0,0 +1,36 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "scapy"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/2f/035d3888f26d999e9680af8c7ddb7ce4ea0fd8d0e01c000de634c22dcf13/scapy-2.6.1.tar.gz", hash = "sha256:7600d7e2383c853e5c3a6e05d37e17643beebf2b3e10d7914dffcc3bc3c6e6c5", size = 2247754, upload-time = "2024-11-05T08:43:23.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/34/8695b43af99d0c796e4b7933a0d7df8925f43a8abdd0ff0f6297beb4de3a/scapy-2.6.1-py3-none-any.whl", hash = "sha256:88a998572049b511a1f3e44f4aa7c62dd39c6ea2aa1bb58434f503956641789d", size = 2420670, upload-time = "2024-11-05T08:43:21.285Z" },
]
[[package]]
name = "thermalcam"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "python-dotenv" },
{ name = "scapy" },
]
[package.metadata]
requires-dist = [
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "scapy", specifier = ">=2.6.1" },
]