This commit is contained in:
Robin Appelman 2026-03-26 16:29:00 +01:00
commit c3b5238a51
13 changed files with 532 additions and 603 deletions

960
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,26 +2,26 @@
name = "taspromto" name = "taspromto"
version = "0.2.0" version = "0.2.0"
authors = ["Robin Appelman <robin@icewind.nl>"] authors = ["Robin Appelman <robin@icewind.nl>"]
edition = "2021" edition = "2024"
rust-version = "1.74.1" rust-version = "1.85.0"
[dependencies] [dependencies]
rumqttc = "0.24.0" rumqttc = "0.25.1"
tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread"] }
dashmap = "6.1.0" dashmap = "6.1.0"
jzon = "0.12.5" jzon = "0.12.5"
warp = "0.3.7" warp = { version = "0.4.2", features = ["server"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
ctrlc = { version = "3.4.5", features = ["termination"] } ctrlc = { version = "3.5.2", features = ["termination"] }
color-eyre = "0.6.3" color-eyre = "0.6.5"
async-stream = "0.3.6" async-stream = "0.3.6"
pin-utils = "0.1.0" pin-utils = "0.1.0"
hostname = "0.4.0" hostname = "0.4.2"
tokio-stream = { version = "0.1.16", features = ["net"] } tokio-stream = { version = "0.1.18", features = ["net"] }
serde = { version = "1.0.213", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
secretfile = "0.1.0" secretfile = "0.1.1"
toml = "0.8.19" toml = "1.1.0"
clap = { version = "4.5.20", features = ["derive"] } clap = { version = "4.5.61", features = ["derive"] }
[profile.release] [profile.release]
lto = true lto = true

View file

@ -1,22 +0,0 @@
FROM rust:alpine AS build
COPY Cargo.toml Cargo.lock ./
# Build with a dummy main to pre-build dependencies
RUN apk add --no-cache alpine-sdk && \
mkdir src && \
echo "fn main(){}" > src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl && \
rm -r src
COPY src/* ./src/
RUN touch src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=build /target/x86_64-unknown-linux-musl/release/taspromto /
EXPOSE 80
CMD ["/taspromto"]

34
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1742394900, "lastModified": 1774313767,
"narHash": "sha256-vVOAp9ahvnU+fQoKd4SEXB2JG2wbENkpqcwlkIXgUC0=", "narHash": "sha256-hy0XTQND6avzGEUFrJtYBBpFa/POiiaGBr2vpU6Y9tY=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "70947c1908108c0c551ddfd73d4f750ff2ea67cd", "rev": "3d9df76e29656c679c744968b17fbaf28f0e923d",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -22,11 +22,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1748868585, "lastModified": 1774271954,
"narHash": "sha256-DrrbahOQAwvNM8l5EuGxxkVS7X5/S59zcG0N9ZWQFhk=", "narHash": "sha256-FbvMOykx7f7uEPdRVzUSABnLjqCdEp22wa0nDkuEd3s=",
"owner": "nix-community", "owner": "nix-community",
"repo": "flakelight", "repo": "flakelight",
"rev": "dfbecd12d99c1bf82906521a6a7d5b75d2aa1ca2", "rev": "c90878b309508083094f465d6aa11b3963f48b9f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -44,11 +44,11 @@
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
}, },
"locked": { "locked": {
"lastModified": 1748205441, "lastModified": 1774538603,
"narHash": "sha256-W+UUBT/l1DSTZo5G43494mRNNspJ2i9jW2QELC9JuMQ=", "narHash": "sha256-IG0fOFNUjselW61zc/jOZU3abmt7FjcRdES9SXeHun4=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "dac3b74a89cebbeb21cc6602e4a346604adbee8b", "rev": "0ef9ce3384617243e1b9e94a7057f879f621f014",
"revCount": 49, "revCount": 69,
"type": "git", "type": "git",
"url": "https://codeberg.org/icewind/mill-scale" "url": "https://codeberg.org/icewind/mill-scale"
}, },
@ -59,16 +59,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1748708770, "lastModified": 1774388614,
"narHash": "sha256-q8jG2HJWgooWa9H0iatZqBPF3bp0504e05MevFmnFLY=", "narHash": "sha256-tFwzTI0DdDzovdE9+Ras6CUss0yn8P9XV4Ja6RjA+nU=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a59eb7800787c926045d51b70982ae285faa2346", "rev": "1073dad219cb244572b74da2b20c7fe39cb3fa9e",
"type": "github" "type": "github"
}, },
"original": { "original": {
"id": "nixpkgs", "id": "nixpkgs",
"ref": "nixos-25.05", "ref": "nixos-25.11",
"type": "indirect" "type": "indirect"
} }
}, },
@ -88,11 +88,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1742697269, "lastModified": 1774535687,
"narHash": "sha256-Lpp0XyAtIl1oGJzNmTiTGLhTkcUjwSkEb0gOiNzYFGM=", "narHash": "sha256-dpKS/8+uB0EoI4mCrpio+xs8Xxry6ZhLLwV8VIbbfrs=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "01973c84732f9275c50c5f075dd1f54cc04b3316", "rev": "75900435aa883f84b038316864b3f60956681523",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -1,6 +1,6 @@
{ {
inputs = { inputs = {
nixpkgs.url = "nixpkgs/nixos-25.05"; nixpkgs.url = "nixpkgs/nixos-25.11";
flakelight = { flakelight = {
url = "github:nix-community/flakelight"; url = "github:nix-community/flakelight";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
@ -12,7 +12,13 @@
}; };
outputs = {mill-scale, ...}: outputs = {mill-scale, ...}:
mill-scale ./. { mill-scale ./. {
packages.tasprompto = import ./package.nix; withOverlays = [
(import ./nix/overlay.nix)
];
packages = {
taspromto = pkgs: pkgs.taspromto;
};
nixosModules = {outputs, ...}: { nixosModules = {outputs, ...}: {
default = { default = {
@ -21,7 +27,7 @@
lib, lib,
... ...
}: { }: {
imports = [./module.nix]; imports = [./nix/module.nix];
config = lib.mkIf config.services.taspromto.enable { config = lib.mkIf config.services.taspromto.enable {
nixpkgs.overlays = [outputs.overlays.default]; nixpkgs.overlays = [outputs.overlays.default];
services.taspromto.package = lib.mkDefault pkgs.taspromto; services.taspromto.package = lib.mkDefault pkgs.taspromto;

20
nix/package.nix Normal file
View file

@ -0,0 +1,20 @@
{
stdenv,
rustPlatform,
lib,
}: let
inherit (lib.sources) sourceByRegex;
inherit (builtins) fromTOML readFile;
src = sourceByRegex ../. ["Cargo.*" "(src)(/.*)?"];
version = (fromTOML (readFile ../Cargo.toml)).package.version;
in
rustPlatform.buildRustPackage rec {
pname = "taspromto";
inherit src version;
cargoLock = {
lockFile = ../Cargo.lock;
};
meta.mainProgram = "taspromto";
}

View file

@ -1,18 +0,0 @@
{
stdenv,
rustPlatform,
lib,
}: let
inherit (lib.sources) sourceByRegex;
src = sourceByRegex ./. ["Cargo.*" "(src)(/.*)?"];
in
rustPlatform.buildRustPackage rec {
pname = "taspromto";
version = "0.1.0";
inherit src;
cargoLock = {
lockFile = ./Cargo.lock;
};
}

View file

@ -38,7 +38,7 @@ pub struct NamesConfig {
#[serde(rename = "mitemp")] #[serde(rename = "mitemp")]
pub mi_temp: BTreeMap<BDAddr, String>, pub mi_temp: BTreeMap<BDAddr, String>,
#[serde(rename = "rftemp")] #[serde(rename = "rftemp")]
pub rf_temp: HashMap<RfDeviceId<'static>, String>, pub rf_temp: HashMap<RfDeviceId, String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -17,8 +17,8 @@ pub struct DeviceStates {
pub devices: HashMap<Device, DeviceState>, pub devices: HashMap<Device, DeviceState>,
pub dsmr_devices: HashMap<Device, DsmrState>, pub dsmr_devices: HashMap<Device, DsmrState>,
pub mi_temp_devices: BTreeMap<BDAddr, MiTempState>, pub mi_temp_devices: BTreeMap<BDAddr, MiTempState>,
pub rf_temp_devices: HashMap<RfDeviceId<'static>, TempState>, pub rf_temp_devices: HashMap<RfDeviceId, TempState>,
active_rf_temp_id: RfDeviceId<'static>, active_rf_temp_id: RfDeviceId,
} }
impl DeviceStates { impl DeviceStates {
@ -65,10 +65,7 @@ impl DeviceStates {
pub fn update_rf(&mut self, payload: &str) { pub fn update_rf(&mut self, payload: &str) {
if let Some(data) = parse_rf_payload(payload) { if let Some(data) = parse_rf_payload(payload) {
let state = self let state = self.rf_temp_devices.entry(data.device_id()).or_default();
.rf_temp_devices
.entry(data.device_id().to_owned())
.or_default();
state.humidity = data.humidity; state.humidity = data.humidity;
state.temperature = data.temperature; state.temperature = data.temperature;
} else { } else {
@ -79,7 +76,7 @@ impl DeviceStates {
pub fn update_rtl(&mut self, device: &str, field: &str, payload: &str) { pub fn update_rtl(&mut self, device: &str, field: &str, payload: &str) {
if self.active_rf_temp_id.name != device { if self.active_rf_temp_id.name != device {
self.active_rf_temp_id = RfDeviceId::default(); self.active_rf_temp_id = RfDeviceId::default();
self.active_rf_temp_id.name = device.to_string().into(); self.active_rf_temp_id.name = device.into();
} }
match field { match field {
"id" => self.active_rf_temp_id.id = payload.parse().unwrap_or_default(), "id" => self.active_rf_temp_id.id = payload.parse().unwrap_or_default(),
@ -92,7 +89,7 @@ impl DeviceStates {
fn update_active_rtl(&mut self, field: &str, payload: &str) { fn update_active_rtl(&mut self, field: &str, payload: &str) {
let state = self let state = self
.rf_temp_devices .rf_temp_devices
.entry(self.active_rf_temp_id.to_owned()) .entry(self.active_rf_temp_id.clone())
.or_default(); .or_default();
match field { match field {
"temperature_F" => { "temperature_F" => {
@ -110,7 +107,7 @@ impl DeviceStates {
self.mi_temp_devices.iter() self.mi_temp_devices.iter()
} }
pub fn rf_temp(&self) -> impl Iterator<Item = (&RfDeviceId<'static>, &TempState)> { pub fn rf_temp(&self) -> impl Iterator<Item = (&RfDeviceId, &TempState)> {
self.rf_temp_devices.iter() self.rf_temp_devices.iter()
} }
@ -333,7 +330,7 @@ pub fn format_device_state<W: Write>(
mut writer: W, mut writer: W,
device: &Device, device: &Device,
state: &DeviceState, state: &DeviceState,
) -> std::fmt::Result { ) -> fmt::Result {
if state.name.is_empty() { if state.name.is_empty() {
println!("{} has no name set, skipping", device.hostname); println!("{} has no name set, skipping", device.hostname);
return Ok(()); return Ok(());
@ -437,7 +434,7 @@ pub fn format_mi_temp_state<W: Write>(
addr: BDAddr, addr: BDAddr,
names: &BTreeMap<BDAddr, String>, names: &BTreeMap<BDAddr, String>,
state: &MiTempState, state: &MiTempState,
) -> std::fmt::Result { ) -> fmt::Result {
// sensor_battery{name="Living Room", mac="58:2D:34:39:1D:5B"} 100 // sensor_battery{name="Living Room", mac="58:2D:34:39:1D:5B"} 100
// sensor_temperature{name="Living Room", mac="58:2D:34:39:1D:5B"} 16.2 // sensor_temperature{name="Living Room", mac="58:2D:34:39:1D:5B"} 16.2
// sensor_humidity{name="Living Room", mac="58:2D:34:39:1D:5B"} 61. // sensor_humidity{name="Living Room", mac="58:2D:34:39:1D:5B"} 61.
@ -485,7 +482,7 @@ pub fn format_rf_temp_state<W: Write>(
channel: &RfDeviceId, channel: &RfDeviceId,
names: &HashMap<RfDeviceId, String>, names: &HashMap<RfDeviceId, String>,
state: &TempState, state: &TempState,
) -> std::fmt::Result { ) -> fmt::Result {
let name = if let Some(name) = names.get(channel) { let name = if let Some(name) = names.get(channel) {
name name
} else { } else {
@ -510,11 +507,7 @@ pub fn format_rf_temp_state<W: Write>(
Ok(()) Ok(())
} }
pub fn format_dsmr_state<W: Write>( pub fn format_dsmr_state<W: Write>(mut writer: W, device: &str, state: &DsmrState) -> fmt::Result {
mut writer: W,
device: &str,
state: &DsmrState,
) -> std::fmt::Result {
let power_total = state.power_total_tariff_1.unwrap_or_default() let power_total = state.power_total_tariff_1.unwrap_or_default()
+ state.power_total_tariff_2.unwrap_or_default(); + state.power_total_tariff_2.unwrap_or_default();
if power_total > 0.0 { if power_total > 0.0 {
@ -715,7 +708,7 @@ pub fn format_pms_state<W: Write>(
device: &Device, device: &Device,
device_state: &DeviceState, device_state: &DeviceState,
state: &PMSState, state: &PMSState,
) -> std::fmt::Result { ) -> fmt::Result {
let name = &device_state.name; let name = &device_state.name;
writeln!( writeln!(
@ -792,33 +785,23 @@ struct RfPayload<'a> {
} }
impl<'a> RfPayload<'a> { impl<'a> RfPayload<'a> {
pub fn device_id(&self) -> RfDeviceId<'a> { pub fn device_id(&self) -> RfDeviceId {
RfDeviceId { RfDeviceId {
name: Cow::Borrowed(self.name), name: self.name.into(),
id: self.id, id: self.id,
channel: self.channel, channel: self.channel,
} }
} }
} }
#[derive(Hash, PartialEq, Eq, Debug, Clone, Default)] #[derive(Hash, PartialEq, Eq, Debug, Default, Clone)]
pub struct RfDeviceId<'a> { pub struct RfDeviceId {
name: Cow<'a, str>, name: String,
id: u16, id: u16,
channel: u8, channel: u8,
} }
impl RfDeviceId<'_> { impl<'de> Deserialize<'de> for RfDeviceId {
pub fn to_owned(&self) -> RfDeviceId<'static> {
RfDeviceId {
name: Cow::Owned(self.name.to_string()),
id: self.id,
channel: self.channel,
}
}
}
impl<'de> Deserialize<'de> for RfDeviceId<'static> {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
@ -828,7 +811,7 @@ impl<'de> Deserialize<'de> for RfDeviceId<'static> {
} }
} }
impl FromStr for RfDeviceId<'static> { impl FromStr for RfDeviceId {
type Err = ParseIntError; type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
@ -837,14 +820,14 @@ impl FromStr for RfDeviceId<'static> {
let id = parts.next().unwrap_or_default().parse()?; let id = parts.next().unwrap_or_default().parse()?;
let channel = parts.next().unwrap_or_default().parse()?; let channel = parts.next().unwrap_or_default().parse()?;
Ok(RfDeviceId { Ok(RfDeviceId {
name: name.to_string().into(), name: name.into(),
id, id,
channel, channel,
}) })
} }
} }
fn parse_rf_payload(payload: &str) -> Option<RfPayload> { fn parse_rf_payload<'a>(payload: &'a str) -> Option<RfPayload<'a>> {
let mut parts = payload.split(";").skip(2); let mut parts = payload.split(";").skip(2);
let name = parts.next()?; let name = parts.next()?;
let id = parts.next()?.strip_prefix("ID=")?.parse().ok()?; let id = parts.next()?.strip_prefix("ID=")?.parse().ok()?;

View file

@ -72,8 +72,8 @@ async fn main() -> Result<()> {
} }
async fn serve(device_states: Arc<Mutex<DeviceStates>>, config: Config) { async fn serve(device_states: Arc<Mutex<DeviceStates>>, config: Config) {
let mi_temp_names = config.names.mi_temp.clone(); let mi_temp_names = config.names.mi_temp;
let rf_temp_names = config.names.rf_temp.clone(); let rf_temp_names = config.names.rf_temp;
let state = warp::any().map(move || device_states.clone()); let state = warp::any().map(move || device_states.clone());
@ -104,7 +104,7 @@ async fn serve(device_states: Arc<Mutex<DeviceStates>>, config: Config) {
ListenConfig::Unix { socket: path } => { ListenConfig::Unix { socket: path } => {
let listener = UnixListener::bind(path).unwrap(); let listener = UnixListener::bind(path).unwrap();
let incoming = UnixListenerStream::new(listener); let incoming = UnixListenerStream::new(listener);
warp::serve(metrics).run_incoming(incoming).await; warp::serve(metrics).incoming(incoming);
} }
} }
} }