This commit is contained in:
Robin Appelman 2024-11-17 18:22:10 +01:00
commit 7c3d4a7867
10 changed files with 1435 additions and 653 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

16
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,16 @@
on: [push, pull_request]
name: Continuous integration
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v27
- uses: icewind1991/attic-action@v1
with:
name: ci
instance: https://cache.icewind.me
authToken: "${{ secrets.ATTIC_TOKEN }}"
- run: nix flake check --keep-going

6
.gitignore vendored
View file

@ -1,2 +1,6 @@
/target
.env
**/*.rs.bk
result
.direnv
.env
config.toml

1706
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,26 @@
[package]
name = "mitemp-prometheus"
version = "0.2.0"
version = "0.3.0"
authors = ["Robin Appelman <robin@icewind.nl>"]
edition = "2018"
edition = "2021"
description = "Expose Xiaomi MI Temperature and Humidity Sensor to prometheus"
license = "MIT/Apache-2.0"
rust-version = "1.74.0"
[dependencies]
dotenv = "0.15.0"
dotenvy = "0.15.7"
main_error = "0.1.0"
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
warp = "0.3"
mitemp = "0.3.1"
tokio-stream = "0.1"
btleplug = "0.9"
env_logger = "0.9"
log = "0.4"
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread"] }
warp = "0.3.7"
mitemp = "0.4.0"
tokio-stream = { version = "0.1.16", features = ["net"] }
btleplug = { version = "0.11.6", features = ["serde"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
serde = { version = "1.0.215", features = ["derive"] }
toml = "0.8.19"
secretfile = "0.1.0"
clap = { version = "4.5.20", features = ["derive"] }
[patch.crates-io]
btleplug = { git = "https://github.com/icewind1991/btleplug/", branch = "bdaddr-deserialize-string" }

View file

@ -4,6 +4,29 @@ Expose Xiaomi MI Temperature and Humidity Sensor to prometheus
## Usage
Configuration can be done by either a config file or environment variables.
### Usage with a config file
Create a `config.toml` like
```toml
[listen]
port = 3030
[names]
"58:2D:34:39:1A:01" = "Sensor 1"
"58:2D:34:39:1A:02" = "Sensor 2"
```
And tun the binary like
```
mitemp-prometheus config.toml
```
### Usage with environment variables
Run the binary with the following environment variables
```dotenv
@ -11,6 +34,8 @@ PORT=3030
NAMES="58:2d:34:39:1a:01=Sensor 1,58:2d:34:39:1a:02=Sensor 2"
```
### Querying metrics
The prometheus metrics will be available at `localhost:3030/metrics`
```
@ -20,20 +45,18 @@ sensor_humidity{name="Sensor 1", mac="58:2d:34:39:1a:01"} 59.2
sensor_battery{name="Sensor 2", mac="58:2d:34:39:1a:02"} 100
sensor_temperature{name="Sensor 2", mac="58:2d:34:39:1a:02"} 16
sensor_humidity{name="Sensor 2", mac="58:2d:34:39:1a:02"} 55.9
sensor_battery{mac="58:2d:34:39:1a:03"} 100
sensor_temperature{mac="58:2d:34:39:1a:03"} 16.1
sensor_humidity{mac="58:2d:34:39:1a:03"} 55.3
```
## License
Licensed under either of
* Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you shall be dual licensed as above, without any
additional terms or conditions.
additional terms or conditions.

107
flake.lock generated Normal file
View file

@ -0,0 +1,107 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1730060262,
"narHash": "sha256-RMgSVkZ9H03sxC+Vh4jxtLTCzSjPq18UWpiM0gq6shQ=",
"owner": "ipetkov",
"repo": "crane",
"rev": "498d9f122c413ee1154e8131ace5a35a80d8fa76",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flakelight": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1731328800,
"narHash": "sha256-gy6/aB9qY+PaOgqRXx5DQNsgKCkjjGKG1HYtth+WTlI=",
"owner": "nix-community",
"repo": "flakelight",
"rev": "76fce036c5e0daf15a926de77f1410ae997c5d4c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "flakelight",
"type": "github"
}
},
"mill-scale": {
"inputs": {
"crane": "crane",
"flakelight": [
"flakelight"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1731858893,
"narHash": "sha256-Ugoi82xv7KOc2VdQejeVvKODP6bt7dkMsDsdRRkN4a8=",
"owner": "icewind1991",
"repo": "mill-scale",
"rev": "9cc1f1c214b1d8c85fea7d8afa56983870c5a59d",
"type": "github"
},
"original": {
"owner": "icewind1991",
"repo": "mill-scale",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1731652201,
"narHash": "sha256-XUO0JKP1hlww0d7mm3kpmIr4hhtR4zicg5Wwes9cPMg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c21b77913ea840f8bcf9adf4c41cecc2abffd38d",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-24.05",
"type": "indirect"
}
},
"root": {
"inputs": {
"flakelight": "flakelight",
"mill-scale": "mill-scale",
"nixpkgs": "nixpkgs"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"mill-scale",
"flakelight",
"nixpkgs"
]
},
"locked": {
"lastModified": 1730255392,
"narHash": "sha256-9pydem8OVxa0TwjUai1PJe0yHAJw556CWCEwyoAq8Ik=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "7509d76ce2b3d22b40bd25368b45c0a9f7f36c89",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

14
flake.nix Normal file
View file

@ -0,0 +1,14 @@
{
inputs = {
nixpkgs.url = "nixpkgs/nixos-24.05";
flakelight = {
url = "github:nix-community/flakelight";
inputs.nixpkgs.follows = "nixpkgs";
};
mill-scale = {
url = "github:icewind1991/mill-scale";
inputs.flakelight.follows = "flakelight";
};
};
outputs = { mill-scale, ... }: mill-scale ./. { };
}

117
src/config.rs Normal file
View file

@ -0,0 +1,117 @@
use btleplug::api::BDAddr;
use main_error::MainError;
use serde::Deserialize;
use std::collections::{BTreeMap, HashMap};
use std::fs::read_to_string;
use std::net::{IpAddr, Ipv4Addr};
use std::path::Path;
use std::str::FromStr;
#[derive(Debug, Deserialize)]
pub struct Config {
pub listen: ListenConfig,
pub names: BTreeMap<BDAddr, String>,
#[allow(dead_code)]
pub mqtt: Option<MqttConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ListenConfig {
Ip {
#[serde(default = "default_address")]
address: IpAddr,
port: u16,
},
Unix {
socket: String,
},
}
fn default_address() -> IpAddr {
Ipv4Addr::UNSPECIFIED.into()
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct MqttConfig {
#[serde(rename = "hostname")]
host: String,
#[serde(default = "default_mqtt_port")]
port: u16,
#[serde(flatten)]
credentials: Option<Credentials>,
}
fn default_mqtt_port() -> u16 {
1883
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Credentials {
Raw {
username: String,
password: String,
},
File {
username: String,
password_file: String,
},
}
#[allow(dead_code)]
impl Credentials {
pub fn username(&self) -> String {
match self {
Credentials::Raw { username, .. } => username.clone(),
Credentials::File { username, .. } => username.clone(),
}
}
pub fn password(&self) -> String {
match self {
Credentials::Raw { password, .. } => password.clone(),
Credentials::File { password_file, .. } => secretfile::load(password_file).unwrap(),
}
}
}
impl Config {
pub fn from_env() -> Result<Config, MainError> {
let mut env: HashMap<String, String> = dotenvy::vars().collect();
let port = env
.get("PORT")
.and_then(|s| u16::from_str(s).ok())
.unwrap_or(80);
let names = env.remove("NAMES").unwrap_or_default();
let names = names
.split(',')
.map(|pair| {
let mut parts = pair.split('=');
if let (Some(Ok(mac)), Some(name)) =
(parts.next().map(BDAddr::from_str), parts.next())
{
Ok((mac, name.to_string()))
} else {
Err(MainError::from("Invalid NAMES"))
}
})
.collect::<Result<BTreeMap<BDAddr, String>, MainError>>()?;
Ok(Config {
listen: ListenConfig::Ip {
port,
address: default_address(),
},
names,
mqtt: None,
})
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Config, MainError> {
let raw = read_to_string(path)?;
Ok(toml::from_str(&raw)?)
}
}

View file

@ -1,41 +1,41 @@
mod config;
use btleplug::api::{Central, Manager as _};
use btleplug::platform::Manager;
use log::info;
use tracing::info;
use main_error::MainError;
use mitemp::{listen, BDAddr, Sensor};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use tokio::{pin, spawn};
use tokio_stream::StreamExt;
use warp::Filter;
use clap::Parser;
use tokio::net::UnixListener;
use tokio_stream::wrappers::UnixListenerStream;
use crate::config::{Config, ListenConfig};
type Cache = Arc<Mutex<HashMap<BDAddr, Sensor>>>;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Config file to use, if omitted the config will be loaded from environment variables
config: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), MainError> {
env_logger::init();
tracing_subscriber::fmt::init();
let cache: Cache = Arc::default();
let mut env: HashMap<String, String> = dotenv::vars().collect();
let port = env
.get("PORT")
.and_then(|s| u16::from_str(s).ok())
.unwrap_or(80);
let names = env.remove("NAMES").unwrap_or_default();
let names = names
.split(',')
.map(|pair| {
let mut parts = pair.split('=');
if let (Some(Ok(mac)), Some(name)) = (parts.next().map(BDAddr::from_str), parts.next())
{
Ok((mac, name.to_string()))
} else {
Err(MainError::from("Invalid NAMES"))
}
})
.collect::<Result<HashMap<BDAddr, String>, MainError>>()?;
let args = Args::parse();
let config = match args.config {
Some(path) => Config::from_file(path)?,
_ => Config::from_env()?,
};
info!("{} devices configured", config.names.len());
let manager = Manager::new().await?;
for adapter in manager.adapters().await? {
@ -60,6 +60,7 @@ async fn main() -> Result<(), MainError> {
});
}
let names = config.names;
let metrics = warp::path!("metrics").map(move || {
let mut result = String::new();
@ -70,7 +71,16 @@ async fn main() -> Result<(), MainError> {
result
});
warp::serve(metrics).run(([0, 0, 0, 0], port)).await;
match config.listen {
ListenConfig::Ip { address, port } => {
warp::serve(metrics).run((address, port)).await;
}
ListenConfig::Unix { socket: path } => {
let listener = UnixListener::bind(path).unwrap();
let incoming = UnixListenerStream::new(listener);
warp::serve(metrics).run_incoming(incoming).await;
}
}
Ok(())
}
@ -78,7 +88,7 @@ async fn main() -> Result<(), MainError> {
fn format<W: Write>(
mut writer: W,
sensor: &Sensor,
names: &HashMap<BDAddr, String>,
names: &BTreeMap<BDAddr, String>,
) -> std::fmt::Result {
if sensor.data.temperature == 0.0 || sensor.data.humidity == 0.0 {
return Ok(());
@ -92,7 +102,7 @@ fn format<W: Write>(
name, sensor.mac, sensor.data.battery
)?;
} else {
info!("Skipping unnamed censor {}", sensor.mac);
info!("Skipping unnamed sensor {}", sensor.mac);
}
}
if let Some(name) = name {
@ -107,7 +117,7 @@ fn format<W: Write>(
name, sensor.mac, sensor.data.humidity
)?;
} else {
info!("Skipping unnamed censor {}", sensor.mac);
info!("Skipping unnamed sensor {}", sensor.mac);
}
Ok(())