feat(rust): add shared Signal client and workspace configuration

This commit is contained in:
Richie committed 2026-10-11 20:11:21 -04:00
1 parent da1976f7ce
commit df045d4c63
9 files changed
+508 -23

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
[package]
name = "signal"
version = "0.1.0"
edition = "2024"
description = "Send Signal alerts through the local signal-cli JSON-RPC daemon"
license = "MIT"
publish = false
[dependencies]
anyhow.workspace = true
serde_json.workspace = true
[target.'cfg(target_os = "linux")'.dev-dependencies]
libc = "0.2"
[lints]
workspace = true
+78
View File
@@ -0,0 +1,78 @@
//! Send alerts through the local signal-cli daemon on Unix systems.
//!
//! ```no_run
//! use signal::Signal;
//!
//! let client = Signal {
//! from_phone: "+15555550100".into(),
//! };
//! client.send("+15555550101", "Service failed")?;
//! client.send("+15555550102", "Service recovered")?;
//! # Ok::<(), anyhow::Error>(())
//! ```
use std::{
io::{BufRead, BufReader, Write},
os::unix::net::UnixStream,
time::Duration,
};
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
/// A sending account for alerts through `/run/signal-cli/socket`.
pub struct Signal {
/// The phone number of an account registered with the local daemon.
pub from_phone: String,
}
impl Signal {
/// Send a message to `to_phone` and wait for the corresponding JSON-RPC response.
///
/// Socket reads and writes use a four-second timeout.
///
/// # Errors
///
/// Returns an error if connecting, writing, or reading fails, the daemon
/// returns invalid JSON or an RPC error, or it closes before responding.
pub fn send(&self, to_phone: &str, body: &str) -> Result<()> {
let mut stream = UnixStream::connect("/run/signal-cli/socket")
.context("Failed to connect to Signal RPC socket")?;
let timeout = Duration::from_secs(4);
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let request = json!({
"jsonrpc": "2.0",
"method": "send",
"params": {
"account": self.from_phone,
"recipient": [to_phone],
"message": body,
},
"id": "signal-alert",
});
writeln!(stream, "{request}").context("Failed to send Signal RPC request")?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
loop {
line.clear();
if reader
.read_line(&mut line)
.context("Failed to read Signal RPC response")?
== 0
|| !line.ends_with('\n')
{
bail!("Signal RPC socket closed before returning a response");
}
let response: Value =
serde_json::from_str(&line).context("Invalid Signal RPC response")?;
if response.get("id").and_then(Value::as_str) != Some("signal-alert") {
continue;
}
if let Some(error) = response.get("error").filter(|value| !value.is_null()) {
bail!("Signal RPC returned an error: {error}");
}
return Ok(());
}
}
}
+147
View File
@@ -0,0 +1,147 @@
#![cfg(test)]
#![cfg(target_os = "linux")]
use std::{
cell::RefCell,
io::{self, BufRead, BufReader, Write},
os::{fd::AsRawFd, unix::net::UnixStream},
thread,
time::{Duration, Instant},
};
use serde_json::{Value, json};
use signal::Signal;
thread_local! {
static CONNECTION: RefCell<Option<UnixStream>> = const { RefCell::new(None) };
}
// Patch libc's connect only in this integration-test executable. Each test
// supplies its own socket pair; no call can reach the real Signal daemon.
// SAFETY: This symbol uses libc's connect ABI and preserves its return/errno
// convention. It never delegates to a real connection attempt.
#[unsafe(no_mangle)]
unsafe extern "C" fn connect(
socket: libc::c_int,
address: *const libc::sockaddr,
length: libc::socklen_t,
) -> libc::c_int {
let path_offset = std::mem::offset_of!(libc::sockaddr_un, sun_path);
let expected = b"/run/signal-cli/socket\0";
if !address.is_null()
&& let Some(path_end) = path_offset.checked_add(expected.len())
&& usize::try_from(length).is_ok_and(|length| length >= path_end)
{
// SAFETY: connect's caller supplies an address valid for length bytes.
// The bounds check above covers the entire pathname slice.
let path = unsafe {
std::slice::from_raw_parts(address.cast::<u8>().add(path_offset), expected.len())
};
if path == expected
&& let Some(stream) = CONNECTION.with_borrow_mut(Option::take)
{
// SAFETY: Both descriptors are live and owned by this thread.
// dup2 replaces the unconnected socket with the test endpoint.
return if unsafe { libc::dup2(stream.as_raw_fd(), socket) } < 0 {
-1
} else {
0
};
}
}
// SAFETY: __errno_location returns this thread's valid errno pointer.
unsafe { *libc::__errno_location() = libc::ECONNREFUSED };
-1
}
fn test_connection() -> UnixStream {
let (client, server) = UnixStream::pair().unwrap();
CONNECTION.with_borrow_mut(|connection| assert!(connection.replace(client).is_none()));
server
}
fn signal() -> Signal {
Signal {
from_phone: "1234567890".into(),
}
}
#[test]
fn reuses_sender_for_different_recipients_and_ignores_unrelated_responses() {
let client = signal();
for (to_phone, body) in [
("0987654321", "failure\nwith details"),
("+15555550102", "another failure"),
] {
let server = test_connection();
let task = thread::spawn(move || {
let mut reader = BufReader::new(server);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
assert_eq!(
request,
json!({
"jsonrpc": "2.0", "method": "send", "id": "signal-alert",
"params": { "account": "1234567890", "recipient": [to_phone], "message": body },
})
);
reader
.get_mut()
.write_all(b"{\"method\":\"receive\"}\n{\"id\":\"other\"}\n{\"id\":\"signal-")
.unwrap();
reader
.get_mut()
.write_all(b"alert\",\"result\":{}}\n")
.unwrap();
});
client.send(to_phone, body).unwrap();
task.join().unwrap();
}
}
#[test]
fn rejects_rpc_errors_invalid_json_and_premature_close() {
for (reply, expected) in [
(
"{\"id\":\"signal-alert\",\"error\":{\"code\":-1}}\n",
"Signal RPC returned an error",
),
("invalid\n", "Invalid Signal RPC response"),
("", "socket closed"),
("{\"id\":\"signal-alert\"}", "socket closed"),
] {
let server = test_connection();
let task = thread::spawn(move || {
let mut reader = BufReader::new(server);
reader.read_line(&mut String::new()).unwrap();
reader.get_mut().write_all(reply.as_bytes()).unwrap();
});
let error = signal().send("0987654321", "failure").unwrap_err();
assert!(format!("{error:#}").contains(expected), "{error:#}");
task.join().unwrap();
}
}
#[test]
fn reports_connection_failure() {
let error = signal().send("0987654321", "failure").unwrap_err();
assert_eq!(error.to_string(), "Failed to connect to Signal RPC socket");
assert_eq!(
error.downcast_ref::<io::Error>().unwrap().kind(),
io::ErrorKind::ConnectionRefused
);
}
#[test]
fn unresponsive_server_times_out() {
let _server = test_connection();
let start = Instant::now();
let error = signal().send("0987654321", "failure").unwrap_err();
assert_eq!(error.to_string(), "Failed to read Signal RPC response");
assert!(matches!(
error.downcast_ref::<io::Error>().unwrap().kind(),
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
));
assert!(start.elapsed() >= Duration::from_secs(4));
}
+10 -11
View File
@@ -6,16 +6,15 @@ description = "Fetch privacy-masked weather for a van and publish it to Home Ass
license = "MIT"
[dependencies]
anyhow = "1.0"
chrono = "0.4"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
log = "0.4"
anyhow.workspace = true
chrono.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
env_logger.workspace = true
log.workspace = true
num-traits = "0.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints.clippy]
all = "deny"
pedantic = "deny"
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
[lints]
workspace = true
+19 -3
View File
@@ -1,10 +1,26 @@
{ rustPlatform }:
{ lib, rustPlatform }:
rustPlatform.buildRustPackage {
pname = "van-weather";
version = "0.1.0";
src = ./.;
src = lib.fileset.toSource {
root = ../..;
fileset = lib.fileset.unions [
../../Cargo.toml
../../Cargo.lock
../../clippy.toml
../.
];
};
cargoLock.lockFile = ./Cargo.lock;
cargoLock.lockFile = ../../Cargo.lock;
cargoBuildFlags = [
"-p"
"van-weather"
];
cargoTestFlags = [
"-p"
"van-weather"
];
meta = {
description = "Privacy-masked van weather publisher for Home Assistant";
+34 -9
View File
@@ -4,6 +4,7 @@ use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use clap::Parser;
use log::{error, info};
use num_traits::ToPrimitive;
use reqwest::{
StatusCode, Url,
blocking::{Client, ClientBuilder},
@@ -25,7 +26,7 @@ const fn decimal_factor(decimals: u32) -> f64 {
let mut remaining = decimals;
while remaining > 0 {
factor *= 10.0;
remaining -= 1;
remaining = remaining.saturating_sub(1);
}
factor
}
@@ -468,18 +469,25 @@ fn weather_updates(weather: &Weather) -> Vec<(String, Value)> {
}
fn sensor(entity_id: &str, state: Option<Value>, attributes: Value) -> Option<(String, Value)> {
state.map(|state| {
let mut payload = serde_json::Map::new();
payload.insert("state".to_owned(), state);
payload.insert("attributes".to_owned(), attributes);
(entity_id.to_owned(), Value::Object(payload))
})
let state = state?;
let mut payload = serde_json::Map::new();
payload.insert("state".to_owned(), state);
payload.insert("attributes".to_owned(), attributes);
Some((entity_id.to_owned(), Value::Object(payload)))
}
#[allow(clippy::cast_possible_truncation)]
fn percent(value: Option<f64>) -> i64 {
// Preserve Python's int(probability * 100) behavior for Home Assistant.
(value.unwrap_or_default() * 100.0) as i64
let value = value.unwrap_or_default() * 100.0;
value.to_i64().unwrap_or_else(|| {
if value.is_nan() {
0
} else if value.is_sign_negative() {
i64::MIN
} else {
i64::MAX
}
})
}
#[cfg(test)]
@@ -562,4 +570,21 @@ mod tests {
assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON);
assert!((mask_coordinate(-122.4194) - (-122.4)).abs() < f64::EPSILON);
}
#[test]
fn percentages_truncate_and_preserve_saturation_for_extreme_values() {
for (value, expected) in [
(None, 0),
(Some(0.129), 12),
(Some(-0.129), -12),
(Some(1.0), 100),
(Some(f64::NAN), 0),
(Some(f64::MAX), i64::MAX),
(Some(f64::MIN), i64::MIN),
(Some(f64::INFINITY), i64::MAX),
(Some(f64::NEG_INFINITY), i64::MIN),
] {
assert_eq!(percent(value), expected);
}
}
}