diff --git a/src/decoder.rs b/src/decoder.rs
index b776867..8ed3bb5 100644
--- a/src/decoder.rs
+++ b/src/decoder.rs
@@ -137,64 +137,6 @@ pub fn handle_service_data(
// --- BTHome Decoder ---
-/// The BTHome v2 device-information byte, i.e. the first byte of the service
-/// data. See .
-struct BthomeDeviceInfo {
- /// Bits 5-7. This decoder only understands version 2.
- version: u8,
- /// Bit 0. When set, everything after this byte is ciphertext.
- encrypted: bool,
-}
-
- BlePacketType::Other => {
- // Every phone, watch and TV in range lands here. Now that each
- // advertisement is processed rather than only the first one per
- // device, saying so on every packet drowns out the readings.
- }
- }
-}
-
-/// Number of value bytes that follow a BTHome object id, or `None` if the
-/// length cannot be determined.
-///
-/// This is what keeps the parser in sync: an object we do not interpret still
-/// has to be skipped by exactly the right number of bytes, otherwise the next
-/// value byte gets read as an object id. Lengths are from
-/// . `tail` is the payload after the object id and
-/// is only needed for the few variable-length objects.
-fn bthome_value_len(object_id: u8, tail: &[u8]) -> Option {
- let len = match object_id {
- // 1-byte sensor values: packet id, battery, count, humidity, moisture,
- // UV index, temperature, channel, light level, settings revision.
- 0x00 | 0x01 | 0x09 | 0x2E | 0x2F | 0x46 | 0x57 | 0x58 | 0x59 | 0x60 | 0x64 | 0x65 => 1,
- // 2-byte sensor values.
- 0x02 | 0x03 | 0x06 | 0x07 | 0x08 | 0x0C | 0x0D | 0x0E | 0x12 | 0x13 | 0x14 | 0x40
- | 0x41 | 0x43 | 0x44 | 0x45 | 0x47 | 0x48 | 0x49 | 0x4A | 0x51 | 0x52 | 0x56 | 0x5A
- | 0x5D | 0x5E | 0x5F | 0x61 => 2,
- // 3-byte sensor values: pressure, illuminance, energy, power, duration, gas.
- 0x04 | 0x05 | 0x0A | 0x0B | 0x42 | 0x4B => 3,
- // 4-byte sensor values.
- 0x4C | 0x4D | 0x4E | 0x4F | 0x50 | 0x55 | 0x5B | 0x5C | 0x62 | 0x63 => 4,
- // Binary sensors and the button event are one byte each.
- 0x0F..=0x11 | 0x15..=0x2D | 0x3A => 1,
- // Command and dimmer events carry a step count for some event types.
- 0x3B => match *tail.first()? {
- 0x03 | 0x04 => 2,
- _ => 1,
- },
- 0x3C => match *tail.first()? {
- 0x01 | 0x02 => 2,
- _ => 1,
- },
- // Text and raw are length-prefixed.
- 0x53 | 0x54 => 1 + usize::from(*tail.first()?),
- _ => return None,
- };
- Some(len)
-}
-
-// --- BTHome Decoder ---
-
/// The BTHome v2 device-information byte, i.e. the first byte of the service
/// data. See .
struct BthomeDeviceInfo {
@@ -262,33 +204,52 @@ fn decode_bthome(payload: &[u8], options: DecodeOptions<'_>) -> Option plaintext,
+ Err(e) => {
+ log::warn!("could not decrypt BTHome payload: {e}");
+ return None;
+ }
+ };
+ decrypted.as_slice()
+ } else {
+ plaintext
+ };
+
+ let mut result = SensorData {
+ format: SensorFormat::BtHome,
+ ..Default::default()
+ };
while let Some((&object_id, tail)) = rest.split_first() {
let Some(len) = bthome_value_len(object_id, tail) else {
- println!(
- " [!] Unknown BTHome object id 0x{object_id:02X}; stopping here rather than \
- guessing its length"
+ log::debug!(
+ "unknown BTHome object id 0x{object_id:02X}; stopping here rather than guessing \
+ its length"
);
break;
};
if tail.len() < len {
- println!(" [!] Truncated BTHome object 0x{object_id:02X}");
+ log::debug!("truncated BTHome object 0x{object_id:02X}");
break;
}
@@ -470,7 +431,10 @@ fn decode_mijia(payload: &[u8], options: DecodeOptions<'_>) -> Result>;
-
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> Result<(), Box> {
let args = Args::parse();
@@ -134,98 +131,86 @@ async fn main() -> Result<(), Box> {
let adapter = session.default_adapter().await?;
adapter.set_powered(true).await?;
- // The advertisement each device last sent, so that a property change which
- // carries no new reading (an RSSI update, say) is not reported twice.
- let mut last_service_data: HashMap = HashMap::new();
- let last_ble_packet = Arc::new(Mutex::new(Instant::now()));
- let (tx, mut rx) = mpsc::unbounded_channel::();
+ log::info!(
+ "scanning on {} ({} configured sensor(s){}, watchdog {}s, cooldown {}s)",
+ adapter.name(),
+ config.configured_sensors(),
+ if config.only_known() {
+ ", ignoring the rest"
+ } else {
+ ""
+ },
+ args.watchdog,
+ args.cooldown
+ );
+
+ // One registry shared by the scan loop, the /metrics endpoint and the
+ // Pushgateway loop; only built when something actually wants it.
+ let registry = (args.metrics_addr.is_some() || args.pushgateway_url.is_some())
+ .then(|| Arc::new(Registry::new()));
+
+ if let Some(addr) = args.metrics_addr {
+ let registry = Arc::clone(
+ registry
+ .as_ref()
+ .expect("registry exists with --metrics-addr"),
+ );
+ tokio::spawn(async move {
+ if let Err(e) = metrics::serve(addr, registry).await {
+ log::error!("metrics endpoint stopped: {e}");
+ }
+ });
+ }
- //
- // 🔄 Discovery + watchdog task
- //
- {
- let adapter = adapter.clone();
- let tx = tx.clone();
- let last_ble_packet = last_ble_packet.clone();
- let watchdog = args.watchdog;
- let cooldown = args.cooldown;
+ if let Some(url) = &args.pushgateway_url {
+ let target = PushTarget::parse(url)?;
+ let registry = Arc::clone(
+ registry
+ .as_ref()
+ .expect("registry exists with --pushgateway-url"),
+ );
+ let interval = Duration::from_secs(args.push_interval.max(1));
+ tokio::spawn(metrics::push_periodically(target, registry, interval));
+ }
- tokio::spawn(async move {
- let mut restart_counter: u64 = 1;
-
- loop {
- println!("🔍 (Re)starting discovery...");
-
- // discover_devices_with_changes() re-emits DeviceAdded every time
- // a device's properties change, which is what turns this into a
- // continuous reader: discover_devices() only reports each device
- // once, so every advertisement after the first was invisible.
- let mut events = match adapter.discover_devices_with_changes().await {
- Ok(ev) => ev,
- Err(e) => {
- eprintln!("❌ Failed to start discovery: {e}");
- sleep(Duration::from_secs(cooldown)).await;
- continue;
- }
- };
-
- loop {
- tokio::select! {
- evt = events.next() => {
- match evt {
- Some(AdapterEvent::DeviceAdded(addr)) => {
- let _ = tx.send(AdapterEvent::DeviceAdded(addr));
- }
- Some(AdapterEvent::DeviceRemoved(addr)) => {
- let _ = tx.send(AdapterEvent::DeviceRemoved(addr));
- }
- Some(_) => {}
- None => {
- println!("⚠️ Discovery stream ended — restarting...");
- break;
- }
- }
- }
-
- _ = sleep(Duration::from_secs(5)) => {
- let elapsed = last_ble_packet.lock().await.elapsed();
- if elapsed > Duration::from_secs(watchdog) {
- println!(
- "⏱ Watchdog: no BLE packets for {elapsed:?}, restarting discovery (count {restart_counter})..."
- );
- restart_counter += 1;
-
- // Drop the current stream (equivalent to disable_le_scan)
- drop(events);
-
- // Wait before restarting (equivalent to Python's 5s delay)
- sleep(Duration::from_secs(cooldown)).await;
-
- break;
- }
- }
- }
- }
+ let settings = scan::Settings {
+ watchdog: Duration::from_secs(args.watchdog),
+ cooldown: Duration::from_secs(args.cooldown),
+ format: args.format,
+ config,
+ exec: args
+ .exec
+ .clone()
+ .map(|program| Hook::new(program, Duration::from_secs(args.exec_interval))),
+ metrics: registry,
+ };
tokio::select! {
result = scan::run(&adapter, settings) => result?,
() = shutdown_requested() => log::info!("shutting down"),
}
- //
- // 📡 Event processing loop
- //
- while let Some(evt) = rx.recv().await {
- match evt {
- AdapterEvent::DeviceAdded(addr) => {
- if let Err(e) =
- handle_device(&adapter, addr, &mut last_service_data, &last_ble_packet).await
- {
- eprintln!("Error handling device {addr}: {e}");
+ Ok(())
+}
+
+/// Resolves once the process is asked to stop, so discovery is torn down cleanly
+/// instead of being killed mid-scan. SIGTERM matters here because that is what
+/// systemd sends.
+async fn shutdown_requested() {
+ #[cfg(unix)]
+ {
+ use tokio::signal::unix::{SignalKind, signal};
+
+ match signal(SignalKind::terminate()) {
+ Ok(mut terminate) => {
+ tokio::select! {
+ () = wait_for_ctrl_c() => {}
+ _ = terminate.recv() => {}
}
}
- AdapterEvent::DeviceRemoved(addr) => {
- last_service_data.remove(&addr);
+ Err(e) => {
+ log::warn!("cannot listen for SIGTERM: {e}");
+ wait_for_ctrl_c().await;
}
}
}
@@ -234,52 +219,10 @@ async fn main() -> Result<(), Box> {
wait_for_ctrl_c().await;
}
-async fn handle_device(
- adapter: &Adapter,
- addr: Address,
- last_service_data: &mut HashMap,
- last_ble_packet: &Mutex,
-) -> Result<()> {
- let device = adapter.device(addr)?;
-
- // One D-Bus round-trip for every property, instead of one round-trip each for
- // the name, the RSSI and the service data. At one advertisement per second
- // per sensor that is the difference the Pi Zero W notices.
- let mut name = None;
- let mut rssi = None;
- let mut service_data = None;
- for property in device.all_properties().await? {
- match property {
- DeviceProperty::Name(value) => name = Some(value),
- DeviceProperty::Rssi(value) => rssi = Some(value),
- DeviceProperty::ServiceData(value) => service_data = Some(value),
- _ => {}
- }
- }
-
- // No service data at all: not something this tool can read.
- let Some(service_data) = service_data else {
- return Ok(());
- };
-
- // Identical to the advertisement we already reported, so some other property
- // changed. Sensors bump a packet counter on every broadcast, so a genuinely
- // new reading always differs here.
- if last_service_data.get(&addr) == Some(&service_data) {
- return Ok(());
+async fn wait_for_ctrl_c() {
+ if let Err(e) = tokio::signal::ctrl_c().await {
+ log::warn!("cannot listen for Ctrl-C: {e}");
+ // Never resolve, so the scan keeps running instead of exiting at once.
+ std::future::pending::<()>().await;
}
-
- if let Some(decoded) = decoder::handle_service_data(&service_data) {
- let name = name.as_deref().unwrap_or("");
- let rssi = rssi.map_or_else(|| "n/a".to_string(), |value| value.to_string());
- println!("📡 {addr} ({name}), RSSI={rssi}");
- println!(" 🔍 Got sensor reading: {decoded:?}");
-
- // ✅ Reset watchdog timer only on actual sensor readings
- *last_ble_packet.lock().await = Instant::now();
- }
-
- last_service_data.insert(addr, service_data);
-
- Ok(())
}