# sdr-rtltcp-discovery

[![CI](https://github.com/jasonherald/sdr-rtltcp-discovery/actions/workflows/ci.yml/badge.svg)](https://github.com/jasonherald/sdr-rtltcp-discovery/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/sdr-rtltcp-discovery.svg)](https://crates.io/crates/sdr-rtltcp-discovery)
[![docs.rs](https://docs.rs/sdr-rtltcp-discovery/badge.svg)](https://docs.rs/sdr-rtltcp-discovery)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)

mDNS/DNS-SD discovery for [`rtl_tcp`]-compatible servers. A server
*announces* itself on the local network; a client *browses* for one
without the user hand-typing `host:port`. Service type:
`_rtl_tcp._tcp.local.` — the same string ShinySDR, `rtl_tcp_client`,
and other SDR tools use by convention, so this interops with them
where they implement discovery.

Pure Rust: built on [`mdns-sd`], so there's **no Avahi / Bonjour
system dependency and no async runtime**. The mDNS daemon runs on its
own thread internally; the browser spawns one more thread that
translates daemon events into the typed events below. `libc` is the
only other dependency (one `gethostname(3)` call on Unix; a
`"localhost"` stub elsewhere).

[`rtl_tcp`]: https://osmocom.org/projects/rtl-sdr/wiki/Rtl-sdr
[`mdns-sd`]: https://docs.rs/mdns-sd

## Browse for servers

```rust,no_run
use sdr_rtltcp_discovery::{Browser, DiscoveryEvent};

# fn main() -> Result<(), Box<dyn std::error::Error>> {
let _browser = Browser::start(|event| match event {
    DiscoveryEvent::ServerAnnounced(server) => {
        let addr = server.addresses.first().copied();
        println!(
            "found {:?} @ {:?}:{}  (tuner={}, {} gain steps)",
            server.txt.nickname, addr, server.port,
            server.txt.tuner, server.txt.gains,
        );
    }
    DiscoveryEvent::ServerWithdrawn { instance_name } => {
        println!("{instance_name} went away");
    }
})?;

// The `Browser` runs until dropped. Park, poll a channel, integrate
// into your event loop — whatever suits. Here we just wait a bit.
std::thread::sleep(std::time::Duration::from_secs(10));
# Ok(())
# }
```

## Announce a server

```rust,no_run
use sdr_rtltcp_discovery::{AdvertiseOptions, Advertiser, TxtRecord, local_hostname};

# fn main() -> Result<(), Box<dyn std::error::Error>> {
let host = local_hostname();           // e.g. "shack-pi"
let txt = TxtRecord {
    tuner: "R820T".to_string(),
    version: env!("CARGO_PKG_VERSION").to_string(),
    gains: 29,
    nickname: host.clone(),
    txbuf: None,
    codecs: None,
    auth_required: None,
};

let _ad = Advertiser::announce(AdvertiseOptions {
    port: 1234,
    instance_name: format!("{host} rtl-sdr"),
    hostname: String::new(),           // empty -> auto-derive from system hostname
    txt,
})?;

// The advertisement stays live until the `Advertiser` is dropped (or
// `.stop()` is called), which unregisters the service cleanly.
# Ok(())
# }
```

## TXT-record schema

Each advertisement carries a small TXT record so a browsing client can
show useful info before it ever connects. Keys are kept short (under
~10 chars) to keep the whole registration inside one UDP packet:

| Key             | Type            | Meaning |
|-----------------|-----------------|---------|
| `tuner`         | string          | Tuner family the dongle reports (`R820T`, `E4000`, …). |
| `version`       | string          | Advertiser version — clients render "running *X*" vs. "unknown rtl_tcp source". |
| `gains`         | u32             | Number of discrete gain steps; the gain *table* is not on the wire. |
| `nickname`      | string          | User-editable label; defaults to the host's hostname. |
| `txbuf`         | usize, optional | Server buffer depth in bytes — a latency hint. Omitted when unset. |
| `codecs`        | u8, optional    | Bitmask of stream codecs the server will negotiate (server-specific). Omitted when unset. |
| `auth_required` | bool, optional  | `true` if the server wants a pre-shared key before connect. Only `Some(true)` is emitted; absence ⇒ "no auth". |

`TxtRecord::to_properties` enforces the per-entry 255-byte limit and a
400-byte total budget; `from_properties` parses an incoming record
back, treating missing optional keys as `None`.

## Minimum supported Rust version

`1.95`. Bumping the MSRV is a minor-version change.

## License

Dual-licensed under either of:

- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
  <https://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or
  <https://opensource.org/licenses/MIT>)

at your option.

### Contribution

Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the
Apache-2.0 license, shall be dual-licensed as above, without any
additional terms or conditions.
