btd: the advertisement carries the robot's IPv4 address

`duck-btctl scan` connects to nothing, which is what makes it the command
to reach for when a robot is unreachable — and it is why a listing could
only report what an advertisement carried. So the question a listing is
most often read to answer, *where do I ssh?*, had no answer in it: the
address is in `net.status`, and reading that costs a connection, a bond
and the PIN, per robot.

Four bytes of IPv4 now go out in a manufacturer-data field under company
id `0xFFFF`, the id the SIG reserves for internal use. `btd::adv` holds
the layout and both halves use it, so the encoder on the robot and the
decoder on the laptop cannot disagree. The budget is the reason it is
four bytes and no more: flags (3), the 128-bit service UUID (18) and this
field (8) spend 29 of the 31 bytes a legacy advertisement holds, which is
also why the SSID is not here and cannot be — it is up to 32 bytes on its
own, and stays a `wifi status` question.

A robot with no address advertises `0.0.0.0` rather than dropping the
field, so `scan` tells three states apart: an address, a robot on no
network, and a robot on a release from before this existed, which
broadcasts no field. The last two want different next moves.

Three things this had to get right:

- `configd` failing to answer keeps the last known address rather than
  clearing it. Clearing it would deregister and re-register the
  advertisement every tick for as long as a `configd` restart lasted,
  with a client watching the address blink;
- BlueZ refusing an advertisement carrying the field is retried without
  it. The arithmetic says it fits, but the byte that overflows is the
  controller's to count — and an advertisement with no address is a robot
  someone can still reach, where a refused one is a robot gone dark;
- `--name` pins the name, not the advertisement. The reconcile loop used
  to be skipped entirely under a pinned name, which was the same thing
  back when the name was all the advertisement carried; a pinned name
  does not pin a DHCP lease.

`0xFFFF` is open to anyone, so the field is never an identity check: it
is read only from a device that also advertised the service UUID.

Assisted-by: Claude:claude-opus-5[1m]
This commit is contained in:
Pierre Rouanet 2026-08-19 15:18:54 +02:00
parent 65d3a43e40
commit 1cd7768757
6 changed files with 526 additions and 72 deletions

View File

@ -15,7 +15,7 @@
//! it a real test of the protocol rather than a reimplementation that could agree with itself.
//!
//! ```text
//! cargo run -p btd --example duck-btctl -- scan
//! cargo run -p btd --example duck-btctl -- scan # robots in range, and their addresses
//! cargo run -p btd --example duck-btctl -- status
//! cargo run -p btd --example duck-btctl -- wifi scan
//! cargo run -p btd --example duck-btctl -- wifi connect "Pollen" --psk secret
@ -26,12 +26,15 @@
//! `DUCK_ROBOT` and `DUCK_PIN` in the environment are the defaults for `--name` and `--pin`, for
//! the machine that talks to the same robot every day. See [`Target`].
use std::net::Ipv4Addr;
use std::time::{Duration, Instant};
use btd::adv;
use btd::framing::{self, Reassembler};
use btd::gatt::{RPC_UUID, SERVICE_UUID};
use btleplug::api::{
Central, CharPropFlags, Characteristic, Manager as _, Peripheral as _, ScanFilter, WriteType,
Central, CharPropFlags, Characteristic, Manager as _, Peripheral as _, PeripheralProperties,
ScanFilter, WriteType,
};
use btleplug::platform::{Manager, Peripheral};
use clap::{Parser, Subcommand};
@ -85,9 +88,62 @@ struct Seen {
identity: String,
local_name: Option<String>,
services: usize,
/// Whether this advertisement carried the duck service UUID, which is the only evidence a
/// listing has: everything better needs a connection, and `scan` deliberately makes none.
/// Whether this advertisement carried the duck service UUID, which is the strongest evidence a
/// listing has: anything better needs a connection, and `scan` deliberately makes none.
duck: bool,
/// What the robot broadcast about its place on the network — see [`Address`], and `btd::adv`
/// for why four bytes of IPv4 and not the SSID too.
address: Address,
}
/// What a device said about its IPv4 address, which is three answers rather than two.
///
/// `Option<Ipv4Addr>` would collapse the two blanks into one, and they send the reader somewhere
/// different: a robot that broadcast `0.0.0.0` has no network, and a robot that broadcast nothing is
/// on a release from before this existed. The first is a wifi problem and the second is an update.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Address {
At(Ipv4Addr),
/// The field was there and said `0.0.0.0`: this robot has no address, because it is on no
/// network or because DHCP has not given it one yet.
///
/// Not called `None`: it sits next to `Option`'s in [`Address::read`], and one of the two is
/// about a robot's network while the other is about a missing field.
Unassigned,
/// No field at all — an older `btd`, or a device that is not a robot.
Unsaid,
}
impl Address {
/// Read from one advertisement, and **only for a robot**.
///
/// `btd` files the address under company id `0xFFFF`, which the Bluetooth SIG leaves open to
/// anyone, so four bytes from `0xFFFF` on an arbitrary device are four bytes of somebody else's
/// business. Reading it only where the duck service UUID was also advertised is what keeps a
/// beacon from being listed with an invented address.
fn read(properties: &PeripheralProperties, duck: bool) -> Self {
if !duck {
return Self::Unsaid;
}
match adv::address_in(&properties.manufacturer_data) {
Some(address) => Self::At(address),
None if adv::has_address_field(&properties.manufacturer_data) => Self::Unassigned,
None => Self::Unsaid,
}
}
/// How it reads on the device's line in a listing, or nothing at all.
///
/// `Unsaid` renders as nothing rather than as "unknown": every non-robot line is `Unsaid`, and a
/// column of "unknown" against a room full of earbuds is noise. The robot on an older release is
/// covered by the note under the list instead, which has room to say what to do about it.
fn note(self) -> Option<String> {
match self {
Self::At(address) => Some(address.to_string()),
Self::Unassigned => Some("no address".to_owned()),
Self::Unsaid => None,
}
}
}
/// Whatever names this device on this platform.
@ -317,7 +373,8 @@ fn choose<T>(found: Vec<(T, String)>, target: &Target) -> Result<(T, String), St
})
}
/// Devices as indented lines: what names each one, what it calls itself, what it is doing.
/// Devices as indented lines: what names each one, what it calls itself, where it is, what it is
/// doing.
///
/// Shared by `scan` and by the failure message, because identifying a robot in a list of earbuds is
/// the same problem whether the list is the answer or the diagnosis — and two renderings of it would
@ -339,6 +396,9 @@ async fn device_list(mut devices: Vec<&Seen>, target: &Target) -> String {
let mut lines: Vec<String> = Vec::new();
for device in devices.iter().take(LISTED_DEVICES) {
let mut notes: Vec<String> = Vec::new();
// The leading note, because it is what the line is read for: `scan` is how someone finds the
// robot to ssh into or point a browser at, and the service count is diagnosis by comparison.
notes.extend(device.address.note());
if device.services > 0 {
notes.push(format!("{} service(s)", device.services));
}
@ -375,8 +435,8 @@ async fn device_list(mut devices: Vec<&Seen>, target: &Target) -> String {
/// Whether the devices that are not robots are listed, or only counted.
///
/// The duck service UUID in the advertisement is the only evidence available to a listing —
/// everything stronger needs a connection, and connecting to 43 devices to ask each whether it is a
/// The duck service UUID in the advertisement is the strongest evidence available to a listing —
/// anything better needs a connection, and connecting to 43 devices to ask each whether it is a
/// robot would be minutes of pairing prompts. So that block is not padding: a robot already bonded
/// with this Mac frequently advertises no services at all, and it is the reason `--name` exists.
///
@ -397,8 +457,12 @@ fn lists_others(verbose: bool, robots: usize) -> bool {
/// What `scan` prints: the robots, and — per [`lists_others`] — everything else.
async fn listing(seen: &[Seen], verbose: bool, target: &Target) -> String {
let (robots, others): (Vec<&Seen>, Vec<&Seen>) = seen.iter().partition(|d| d.duck);
// Kept before `device_list` consumes the vector, since it decides the second block below.
// Kept before `device_list` consumes the vector, since they decide the blocks below.
let found = robots.len();
let silent = robots
.iter()
.filter(|d| d.address == Address::Unsaid)
.count();
let mut out = if robots.is_empty() {
"no robot advertised the duck service.".to_owned()
@ -410,6 +474,18 @@ async fn listing(seen: &[Seen], verbose: bool, target: &Target) -> String {
)
};
// A robot whose line carries no address at all is on a release from before `btd` broadcast one,
// and its line cannot say so: an absent field looks the same as a device that never had one. Said
// once, below the list, where there is room for what to do about it — and only when it happened,
// because on a bench of current robots this sentence is noise.
if silent > 0 {
out.push_str(&format!(
"\n\n{silent} of them broadcast no address, which is a release from before `btd` \
advertised one. `duck-btctl wifi status` still reports it; updating the robot puts it \
in this list."
));
}
if !others.is_empty() {
if lists_others(verbose, found) {
let anonymous = others.iter().filter(|d| d.local_name.is_none()).count();
@ -438,12 +514,10 @@ async fn listing(seen: &[Seen], verbose: bool, target: &Target) -> String {
/// the radio — while a list the robot is missing from points at the robot.
///
/// And the robot can be *in* that list, unrecognisable. `btd` advertises flags (3 bytes), a 128-bit
/// service UUID (18) and the robot's name (2 + its length), so a name of more than **8 characters**
/// is past the 31 bytes a legacy advertisement holds — and the name travels in the scan response, a
/// second exchange that can be missed on its own. `radxa-zero3` was 11, and the derived default
/// `duck-c51b` is 9, so this is still the normal case rather than the edge one. A device reported
/// with no name and no services is therefore a plausible robot, which is why the unnamed ones are
/// listed rather than filtered out.
/// service UUID (18) and the address field (8, see `btd::adv`), which is 29 of the 31 bytes a legacy
/// advertisement holds — so the name never travels in it. It goes in the scan response, a second
/// exchange that can be missed on its own. A device reported with no name and no services is
/// therefore a plausible robot, which is why the unnamed ones are listed rather than filtered out.
async fn nothing_found(seen: &[Seen], target: &Target) -> String {
if seen.is_empty() {
return format!(
@ -544,7 +618,7 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
/// List robots in range, and stop.
/// List robots in range with the address each one broadcast, and stop.
Scan,
/// Version handshake plus update status.
Status,
@ -684,6 +758,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
local_name: properties.local_name.clone(),
services: properties.services.len(),
duck,
address: Address::read(&properties, duck),
});
if list_only {
@ -1325,6 +1400,70 @@ mod tests {
);
}
/// One advertisement, as `btleplug` would report it.
fn advertised(
duck: bool,
manufacturer_data: &[(u16, Vec<u8>)],
) -> (PeripheralProperties, bool) {
(
PeripheralProperties {
manufacturer_data: manufacturer_data.iter().cloned().collect(),
..Default::default()
},
duck,
)
}
/// The whole point of the change: a listing says where to reach the robot, with no connection.
#[test]
fn a_robot_broadcasts_where_it_is() {
let (properties, duck) = advertised(
true,
&[(
adv::COMPANY_ID,
adv::address_data(Some(Ipv4Addr::new(192, 168, 1, 42))),
)],
);
let address = Address::read(&properties, duck);
assert_eq!(address, Address::At(Ipv4Addr::new(192, 168, 1, 42)));
assert_eq!(address.note().as_deref(), Some("192.168.1.42"));
}
/// The two blanks are not one blank. A robot with no wifi is a wifi problem; a robot that said
/// nothing is an update — and the listing sends the reader somewhere different for each.
#[test]
fn no_wifi_and_no_field_read_differently() {
let (properties, duck) = advertised(true, &[(adv::COMPANY_ID, adv::address_data(None))]);
assert_eq!(Address::read(&properties, duck), Address::Unassigned);
assert_eq!(
Address::read(&properties, duck).note().as_deref(),
Some("no address")
);
let (properties, duck) = advertised(true, &[]);
assert_eq!(Address::read(&properties, duck), Address::Unsaid);
assert_eq!(
Address::read(&properties, duck).note(),
None,
"nothing on the line; the note under the list covers it"
);
}
/// `0xFFFF` is the company id the SIG leaves open to anyone, so four bytes of it on a device that
/// never advertised the duck service are somebody else's four bytes. Listing an earbud with an
/// invented address would be worse than listing it with none.
#[test]
fn only_a_robot_is_read_for_an_address() {
let (properties, duck) = advertised(
false,
&[(
adv::COMPANY_ID,
adv::address_data(Some(Ipv4Addr::new(10, 0, 0, 1))),
)],
);
assert_eq!(Address::read(&properties, duck), Address::Unsaid);
}
/// The PIN matters more than the name does — a robot with a real one needs it on every
/// command — and an empty `DUCK_PIN` left over from a script must not become the PIN, or the
/// robot answers "wrong PIN" for a PIN nobody chose.

125
btd/src/adv.rs Normal file
View File

@ -0,0 +1,125 @@
//! What the advertisement carries besides the name: the robot's IPv4 address.
//!
//! Platform-independent, and here rather than in [`crate::bluez`] for [`crate::gatt`]'s reason —
//! it is wire contract. The robot encodes with [`address_data`] and `duck-btctl` decodes with
//! [`address_in`], so the two halves cannot disagree about the layout. A decoder written
//! separately in the client would agree only with itself.
//!
//! ## Why the advertisement rather than a call
//!
//! `net.status` already reports the address, and reading it costs a connection, a bond and the
//! PIN — per robot. `duck-btctl scan` deliberately connects to nothing, which is what makes it the
//! command to reach for when a robot is unreachable, so a listing can only report what an
//! advertisement carries. Broadcasting the address is therefore the only way `scan` can answer
//! "where do I ssh?", and that is the question a listing is most often read to answer.
//!
//! ## Why four bytes and no more
//!
//! A legacy advertisement holds **31 bytes**, and `btd` already spends 21: flags (3) and a
//! 128-bit service UUID (2 + 16). One manufacturer-data field costs 2 for its header and 2 for the
//! company id, so the payload has 6 bytes to live in and this one uses 4. The name is not in that
//! budget — BlueZ puts a Local Name in the scan response, which has 31 bytes of its own.
//!
//! That is the whole reason the SSID is not here too: an SSID is up to 32 bytes on its own, so no
//! version of it fits. It stays a `wifi status` question.
//!
//! ## Why the address is always present
//!
//! A robot with no wifi advertises [`Ipv4Addr::UNSPECIFIED`] rather than dropping the field. The
//! field is then evidence in itself: absent means a robot on a release that predates this, present
//! and zero means a robot that has no address, and those two want different next moves. Dropping
//! the field would collapse them into one blank column.
use std::collections::HashMap;
use std::net::Ipv4Addr;
/// The company id the payload is filed under.
///
/// `0xFFFF` is the id the Bluetooth SIG reserves for internal and interoperability testing, and it
/// is the correct choice for a project that has not been assigned one. Anyone else may use it too,
/// so **this is not an identity check**: [`address_in`] is only ever asked about a device that
/// already advertised [`crate::gatt::SERVICE_UUID`], which is the discriminator.
pub const COMPANY_ID: u16 = 0xFFFF;
/// The robot's address as it goes into the advertisement.
///
/// `None` — no wifi, or `configd` would not say — becomes [`Ipv4Addr::UNSPECIFIED`] rather than an
/// absent field, for the reason in this module's docs.
pub fn address_data(address: Option<Ipv4Addr>) -> Vec<u8> {
address.unwrap_or(Ipv4Addr::UNSPECIFIED).octets().to_vec()
}
/// The address a scan reported, if it reported one.
///
/// `None` covers three cases that a listing renders differently and this function does not
/// distinguish, because it cannot: no field at all, a field of the wrong length, and a field
/// saying `0.0.0.0`. The caller has the advertisement and can tell the first from the third; see
/// `duck-btctl`'s listing, which does.
pub fn address_in(manufacturer_data: &HashMap<u16, Vec<u8>>) -> Option<Ipv4Addr> {
let bytes: [u8; 4] = manufacturer_data
.get(&COMPANY_ID)?
.as_slice()
.try_into()
.ok()?;
Some(Ipv4Addr::from(bytes)).filter(|address| !address.is_unspecified())
}
/// Whether this device broadcast an address field at all, however it reads.
///
/// Separate from [`address_in`] because "an older release, which broadcasts nothing" and "a robot
/// that is not on wifi" are the two things a blank address could mean, and a listing that cannot
/// tell them apart sends the reader to check the wrong thing.
pub fn has_address_field(manufacturer_data: &HashMap<u16, Vec<u8>>) -> bool {
manufacturer_data
.get(&COMPANY_ID)
.is_some_and(|data| data.len() == 4)
}
#[cfg(test)]
mod tests {
use super::*;
/// The round trip both halves depend on.
#[test]
fn an_address_survives_the_advertisement() {
let address = Ipv4Addr::new(192, 168, 1, 42);
let data = HashMap::from([(COMPANY_ID, address_data(Some(address)))]);
assert_eq!(address_in(&data), Some(address));
assert!(has_address_field(&data));
}
/// A robot with no wifi is distinguishable from one that never spoke about addresses: both
/// have no address, and only one carried the field.
#[test]
fn no_wifi_is_a_present_field_and_no_address() {
let data = HashMap::from([(COMPANY_ID, address_data(None))]);
assert_eq!(address_in(&data), None);
assert!(has_address_field(&data));
let nothing = HashMap::new();
assert_eq!(address_in(&nothing), None);
assert!(!has_address_field(&nothing));
}
/// Four bytes exactly. Anything else is another vendor using `0xFFFF`, or a format this
/// client does not know, and either way it is not an address.
#[test]
fn a_payload_of_the_wrong_length_is_not_an_address() {
for length in [0, 1, 3, 5, 16] {
let data = HashMap::from([(COMPANY_ID, vec![1; length])]);
assert_eq!(address_in(&data), None, "{length} bytes");
assert!(!has_address_field(&data), "{length} bytes");
}
}
/// The field is a fifth of the advertisement, and the budget in this module's docs is the
/// reason nothing else fits. Asserted so that a payload that grows has to come back here.
#[test]
fn the_payload_fits_the_budget() {
const FLAGS: usize = 3;
const SERVICE_UUID: usize = 2 + 16;
const MANUFACTURER_HEADER: usize = 2 + 2;
let spent = FLAGS + SERVICE_UUID + MANUFACTURER_HEADER + address_data(None).len();
assert!(spent <= 31, "{spent} bytes of a 31-byte advertisement");
}
}

View File

@ -23,6 +23,7 @@
//! **Untested against hardware.** It type-checks for aarch64 and has never met a real central.
//! Treat what follows as intent until someone connects a phone.
use std::net::Ipv4Addr;
use std::sync::Arc;
use std::time::Duration;
@ -90,13 +91,18 @@ const ADV_INTERVAL_MAX: Duration = Duration::from_millis(150);
/// waiting for the motor bus rather than giving up on it.
const ADAPTER_RETRY: Duration = Duration::from_secs(5);
/// How long to wait for `configd` to say what the robot is called.
/// How long to wait for `configd` to say what the robot is called, or what address it has.
///
/// Nothing is blocked on the answer — unlike the PIN, where BlueZ holds a pairing exchange open —
/// so this is generous enough to survive a loaded board rather than tuned for a spinner.
const NAME_TIMEOUT: Duration = Duration::from_secs(5);
///
/// It bounds [`ask_address`] as much as [`ask_name`], and that matters more there: `net.status`
/// costs `configd` a handful of D-Bus round trips to NetworkManager, and NetworkManager mid-scan is
/// slow. A late answer costs one poll's worth of a stale address, which is what the fallback in
/// those two functions is for.
const ASK_TIMEOUT: Duration = Duration::from_secs(5);
/// How often the advertised name is reconciled with `configd`'s.
/// How often the advertisement is reconciled with what `configd` says — the name, and the address.
///
/// **Polled rather than event-driven, deliberately.** `btd` forwards `system.setName` to `configd`
/// without reading the reply (`upstream::Pool` merges lines for the client, and interpreting them
@ -109,7 +115,14 @@ const NAME_TIMEOUT: Duration = Duration::from_secs(5);
/// cost is a socket connect and one line every few seconds, forever, which is far below the noise
/// floor of a daemon that already waits 73 seconds for a radio. A `system.*` notification from
/// `configd` would be the tidier answer and is a protocol change nobody needs yet.
const NAME_POLL: Duration = Duration::from_secs(5);
///
/// **The address is asked on the same tick, at the same cadence**, which is faster than a DHCP
/// lease could ever move. Two questions rather than one is a second socket connect and a `net.status`
/// that `configd` answers out of NetworkManager, and splitting the cadences would buy back some of
/// that at the price of a second timer and an address that lags a `wifi connect` by half a minute.
/// The robot has just been given a network at that moment, and the address is the thing whoever did
/// it is waiting to read.
const ADV_POLL: Duration = Duration::from_secs(5);
/// Serve BLE for as long as this process lives, across an adapter that comes and goes.
///
@ -211,11 +224,23 @@ async fn serve_on_an_adapter(
None
};
// `bd_addr` rather than `address`, because the advertisement now carries an IPv4 one too and a
// journal with both spelled `address` reads as one field contradicting itself.
//
// `max_adv_len` is logged because it is the budget `crate::adv` is written against: the payload
// fits 31 bytes, and a controller that reports less is the one place that assumption fails. It
// is the first thing to read if a robot ever advertises its name but no address.
tracing::warn!(
adapter = adapter.name(),
address = %adapter.address().await?,
bd_addr = %adapter.address().await?,
service = %SERVICE_UUID,
pairing = require_pairing,
max_adv_len = adapter
.supported_advertising_capabilities()
.await
.ok()
.flatten()
.map(|caps| caps.max_advertisement_length),
"serving BLE"
);
@ -224,9 +249,15 @@ async fn serve_on_an_adapter(
// carried `/etc/hostname` while `system.setName` wrote a name nothing ever read: every board
// flashed from one image appeared as `radxa-zero3`, and renaming one changed nothing a phone
// could see, not even after a restart.
let advertised = match &name.pinned {
Some(pinned) => pinned.clone(),
None => ask_name(&sockets, &name.fallback).await,
let advertised = Advertised {
name: match &name.pinned {
Some(pinned) => pinned.clone(),
None => ask_name(&sockets, &name.fallback).await,
},
// Asked before the first advertisement rather than left to the first reconcile tick: a
// robot that boots onto a network it already knows would otherwise broadcast `0.0.0.0` for
// the first few seconds, and a listing cannot tell that from a robot with no wifi at all.
address: ask_address(&sockets, None).await,
};
let handle = Some(advertise(&adapter, &advertised).await?);
@ -440,23 +471,57 @@ async fn serve_on_an_adapter(
// handles to nothing and advertising nothing, with no way back short of a restart nobody knew
// to perform. Returning hands the caller a bring-up on the adapter's next appearance.
//
// The name is reconciled *alongside* that wait rather than after it, so losing the adapter ends
// both: whichever finishes first ends the bring-up, and the reconcile is dropped with the
// advertisement handle it owns.
// The advertisement is reconciled *alongside* that wait rather than after it, so losing the
// adapter ends both: whichever finishes first ends the bring-up, and the reconcile is dropped
// with the advertisement handle it owns.
//
// It runs even when `--name` pins the name, because the address moves on its own and a pinned
// name never meant a frozen advertisement — before the address was in it, the two were the same
// thing. So the pin suppresses the *question*, not the loop.
if name.pinned.is_some() {
tracing::info!(name = %advertised, "--name pins the advertisement; not reconciling");
watch_adapter(&adapter).await;
} else {
tokio::select! {
() = watch_adapter(&adapter) => {}
// Never completes on its own.
() = reconcile_name(&adapter, &for_reconcile, advertised, handle) => {}
}
tracing::info!(
name = %advertised.name,
"--name pins the advertised name; only the address is reconciled"
);
}
tokio::select! {
() = watch_adapter(&adapter) => {}
// Never completes on its own.
() = reconcile_advertisement(
&adapter,
&for_reconcile,
advertised,
name.pinned.is_some(),
handle,
) => {}
}
Ok(())
}
/// Advertise the service under `name`, and make that the adapter's name too.
/// What the advertisement says about the robot: what it is called, and where it is on the network.
///
/// One struct rather than two arguments threaded through the reconcile loop, so that "has anything
/// moved" is one comparison. Adding a third field would otherwise mean finding every place that
/// compares the pair — and `crate::adv` explains why there is no room for a third field anyway.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Advertised {
name: String,
/// `None` is a robot with no IPv4 address, which goes out as `0.0.0.0` — see [`crate::adv`] for
/// why the field is broadcast either way.
address: Option<Ipv4Addr>,
}
impl std::fmt::Display for Advertised {
/// For the journal, where the interesting line is the one that says what changed.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.address {
Some(address) => write!(f, "{} at {address}", self.name),
None => write!(f, "{} with no address", self.name),
}
}
}
/// Advertise the service under this name and address, and make the name the adapter's too.
///
/// The handle deregisters on drop, so the caller holds it for as long as the robot should be
/// visible.
@ -475,50 +540,84 @@ async fn serve_on_an_adapter(
/// the one nothing in this repo could see.
///
/// Setting the alias is therefore part of naming the robot rather than a nicety, and it belongs
/// here so that no path can publish a name without it: [`reconcile_name`] re-advertises on every
/// rename and comes through this function to do it. The alias persists in BlueZ's own state, so
/// the write is skipped when it already says the right thing.
/// here so that no path can publish a name without it: [`reconcile_advertisement`] re-advertises on
/// every rename and comes through this function to do it. The alias persists in BlueZ's own state,
/// so the write is skipped when it already says the right thing.
///
/// A failure to set it is logged and not propagated. The alias is worth less than being visible at
/// all, and returning an error here would take the advertisement down with it.
///
/// **The address field is dropped rather than allowed to fail the registration.** The arithmetic in
/// [`crate::adv`] says the payload fits, but the byte that overflows a legacy advertisement is the
/// controller's to count, not ours — and BlueZ refuses the whole registration when it does not fit.
/// On a robot whose only front door may be BLE, that trade is not close: an advertisement with no
/// address is a robot someone can still reach, and a refused one is a robot that has gone dark. Same
/// reasoning as the alias above, one step further down.
async fn advertise(
adapter: &bluer::Adapter,
name: &str,
advertised: &Advertised,
) -> bluer::Result<bluer::adv::AdvertisementHandle> {
let name = advertised.name.as_str();
if adapter.alias().await.ok().as_deref() != Some(name)
&& let Err(e) = adapter.set_alias(name.to_owned()).await
{
tracing::warn!(error = %e, name, "cannot set the adapter alias; the GAP name stays stale");
}
adapter
.advertise(Advertisement {
service_uuids: [SERVICE_UUID].into_iter().collect(),
discoverable: Some(true),
local_name: Some(name.to_owned()),
min_interval: Some(ADV_INTERVAL_MIN),
max_interval: Some(ADV_INTERVAL_MAX),
..Default::default()
})
.await
let advertisement = |address: Option<Vec<u8>>| Advertisement {
service_uuids: [SERVICE_UUID].into_iter().collect(),
manufacturer_data: address
.map(|data| [(crate::adv::COMPANY_ID, data)].into_iter().collect())
.unwrap_or_default(),
discoverable: Some(true),
local_name: Some(name.to_owned()),
min_interval: Some(ADV_INTERVAL_MIN),
max_interval: Some(ADV_INTERVAL_MAX),
..Default::default()
};
let with_address = advertisement(Some(crate::adv::address_data(advertised.address)));
match adapter.advertise(with_address).await {
Ok(handle) => Ok(handle),
Err(e) => {
tracing::warn!(
error = %e,
"BlueZ refused the advertisement carrying the address; retrying without it, so \
`duck-btctl scan` will show this robot with no address at all"
);
adapter.advertise(advertisement(None)).await
}
}
}
/// Keep the advertised name in step with `configd`'s. Never returns.
/// Keep the advertisement in step with what `configd` says — name and address. Never returns.
///
/// Owns the advertisement handle, because changing the name means deregistering one advertisement
/// and registering another — nothing else may be holding it while that happens.
async fn reconcile_name(
/// Owns the advertisement handle, because changing either means deregistering one advertisement and
/// registering another — nothing else may be holding it while that happens.
///
/// `pinned_name` is `--name`: the name is then this process's own and there is nobody to ask about
/// it, so only the address is reconciled. The loop still runs, because a pinned name does not pin a
/// DHCP lease.
async fn reconcile_advertisement(
adapter: &bluer::Adapter,
sockets: &Sockets,
mut advertised: String,
mut advertised: Advertised,
pinned_name: bool,
mut handle: Option<bluer::adv::AdvertisementHandle>,
) {
loop {
tokio::time::sleep(NAME_POLL).await;
tokio::time::sleep(ADV_POLL).await;
let current = ask_name(sockets, &advertised).await;
let current = Advertised {
name: if pinned_name {
advertised.name.clone()
} else {
ask_name(sockets, &advertised.name).await
},
address: ask_address(sockets, advertised.address).await,
};
// `handle` is `None` only after a failed re-advertise, and then the robot is invisible —
// so retry regardless of whether the name moved.
// so retry regardless of whether anything moved.
if current == advertised && handle.is_some() {
continue;
}
@ -531,16 +630,16 @@ async fn reconcile_name(
match advertise(adapter, &current).await {
Ok(new) => {
if current == advertised {
tracing::info!(name = %current, "advertising again after a failure");
tracing::info!(advertising = %current, "advertising again after a failure");
} else {
tracing::info!(from = %advertised, to = %current, "renamed; advertising");
tracing::info!(from = %advertised, to = %current, "advertisement changed");
}
handle = Some(new);
advertised = current;
}
// Left for the next tick rather than fatal, and never propagated: this is inside a
// bring-up whose whole point is that radio faults do not end the process.
Err(e) => tracing::error!(error = %e, name = %current, "cannot advertise"),
Err(e) => tracing::error!(error = %e, advertising = %current, "cannot advertise"),
}
}
}
@ -557,7 +656,7 @@ async fn ask_name(sockets: &Sockets, fallback: &str) -> String {
"configd",
socket,
&duck_ipc_proto::Call::SystemInfo,
NAME_TIMEOUT,
ASK_TIMEOUT,
)
.await
.and_then(|response| {
@ -573,6 +672,49 @@ async fn ask_name(sockets: &Sockets, fallback: &str) -> String {
}
}
/// What `configd` says the robot's IPv4 address is, or `last` if it will not say.
///
/// **The two failures are not the same answer, and conflating them made the advertisement flap.**
/// `configd` reporting no address is a robot that is not on wifi, and that clears the field.
/// `configd` not answering — restarting, or NetworkManager taking longer than [`ASK_TIMEOUT`] —
/// says nothing about the robot's network, and clearing the field on it would deregister and
/// re-register the advertisement on every tick for as long as the outage lasted, with a client
/// watching the address blink. So an outage keeps the last known address, exactly as [`ask_name`]
/// keeps the last known name.
///
/// Only IPv4, because only IPv4 fits — see [`crate::adv`].
///
/// `debug` rather than `warn` for the same reason as [`ask_name`]: this runs every few seconds.
async fn ask_address(sockets: &Sockets, last: Option<Ipv4Addr>) -> Option<Ipv4Addr> {
let socket = sockets.path(crate::route::Upstream::Config);
match crate::upstream::ask(
"configd",
socket,
&duck_ipc_proto::Call::NetStatus,
ASK_TIMEOUT,
)
.await
.and_then(|response| {
response
.result_as::<duck_ipc_proto::NetStatusResult>()
.map_err(|e| e.to_string())
}) {
// Parsed rather than trusted: `ip4` is whatever NetworkManager put in `address-data`, and a
// string this cannot parse is not something to broadcast four bytes of.
Ok(status) => status.ip4.and_then(|address| match address.parse() {
Ok(address) => Some(address),
Err(e) => {
tracing::warn!(error = %e, address, "configd reported an unparseable IPv4 address");
None
}
}),
Err(e) => {
tracing::debug!(error = %e, "configd would not say the robot's address");
last
}
}
}
/// Return once the adapter stops being usable.
///
/// A poll, not an event stream. `bluer` can report adapter removal, but the failure this has to

View File

@ -25,10 +25,12 @@
//! holds the connections to the services that own the answers.
//!
//! `net.*` and `system.*` — wifi, name, reboot — go to `configd`, one arm each in [`route`]'s
//! table. The robot's name is the one thing `btd` reads back rather than only forwarding: it has to
//! advertise it, so [`bluez`] asks `configd` what the robot is called and keeps the advertisement
//! in step with it.
//! table. The robot's name and its IPv4 address are the two things `btd` reads back rather than
//! only forwarding: both go in the advertisement, so [`bluez`] asks `configd` for them and keeps
//! the advertisement in step. [`adv`] is the layout of the address field, shared with the client
//! that decodes it.
pub mod adv;
#[cfg(target_os = "linux")]
pub mod bluez;
pub mod framing;

View File

@ -599,11 +599,12 @@ bump would rename every robot in the field, and nobody would ever connect the tw
Four hex characters is 65 536 possibilities, so three robots in a room collide about once in 22 000
times. This is a default meant to be *distinguishable*, not a unique key.
A name of more than **8 characters** does not fit the 31 bytes of a legacy advertisement once flags
and the 128-bit service UUID are counted, so it travels in the scan response — a second exchange a
central can miss. `duck-c51b` is 9, one over. Dropping to three hex characters would fit, at 4 096
possibilities; whether that trade is worth taking is unmeasured, because it depends on how BlueZ
packs the two payloads.
The name travels in the **scan response**, not the advertisement, and now always will: flags (3),
the 128-bit service UUID (18) and the address field (8, see below) spend 29 of the 31 bytes a legacy
advertisement holds. Before the address it was 21, and a name of 8 characters or fewer could have
fitted alongside — `duck-c51b` is 9, one over, so in practice it never did. A scan response is a
second exchange a central can miss on its own, which is why a device reported with no name and no
services is a plausible robot rather than something to filter out.
#### The advertised name is the one the robot was given
@ -630,9 +631,39 @@ problem with a client fix.
Reconciled rather than event-driven, deliberately. `btd` forwards `system.setName` without reading
the reply — interpreting replies is what this daemon avoids — and re-asking the moment it forwards
one races the write it just forwarded. Polling is fewer moving parts and covers renames made through
`robotctl`, which never cross `btd` at all. `--name` pins the advertisement for bench work. An
unreachable `configd` falls back to the hostname: `btd` is on the recovery path and has to come up
when the rest of the robot has not.
`robotctl`, which never cross `btd` at all. `--name` pins the *name* for bench work — the reconcile
loop still runs, because the address below moves whether or not the name does. An unreachable
`configd` falls back to the hostname: `btd` is on the recovery path and has to come up when the rest
of the robot has not.
#### The advertisement carries the robot's IPv4 address
`duck-btctl scan` connects to nothing, which is what makes it the command to reach for when a robot
is unreachable — and it is why a listing can only report what an advertisement carries. So the
question a listing is most often read to answer, *where do I ssh?*, had no answer in it: the address
is in `net.status`, and reading that costs a connection, a bond and the PIN, per robot.
Four bytes of IPv4 go in a manufacturer-data field under company id `0xFFFF` — the id the Bluetooth
SIG reserves for internal and interoperability testing, which is the right one for a project that
has not been assigned one. Anyone may use that id, so **the field is not an identity check**: it is
read only from a device that also advertised the service UUID, which is the discriminator.
**The SSID is not in it and cannot be.** An SSID is up to 32 bytes on its own, against the 6 bytes
of payload the budget above leaves. It stays a `wifi status` question, and that is the one to ask
when the address in a listing says something surprising.
A robot with no address advertises `0.0.0.0` rather than dropping the field, so a listing can tell
three states apart: an address, a robot with no network, and a robot on a release from before this
existed, which broadcasts no field at all. Collapsing the last two would send the reader to check
wifi on a robot that needs an update.
The address is reconciled on the same tick as the name, every few seconds — far faster than a DHCP
lease moves, and the point is the other case: whoever just ran `wifi connect` is waiting to read the
address it produced. `configd` failing to answer keeps the last known address rather than clearing
it, or a `configd` restart would deregister and re-register the advertisement every tick with a
client watching the address blink. And if BlueZ ever refuses an advertisement carrying the field,
`btd` retries without it: an advertisement with no address is a robot someone can still reach, and a
refused one is a robot gone dark.
#### Provisioning can name a board, and does not have to

View File

@ -41,9 +41,24 @@ cargo uninstall btd --bin btctl
duck-btctl scan
```
```
1 robot(s) advertising the duck service:
aa:bb:cc:dd:ee:ff duck-c51b — 192.168.1.42, 1 service(s) ← DUCK_ROBOT
7 other device(s) in range, not listed. …
```
Robots only, with everything else in radio range counted rather than listed. `--verbose` expands
that list, and it is worth reading when the robot you want is not in the first one.
Each robot broadcasts its IPv4 address, so this is also where the address to ssh to comes from. No
connection is made and no PIN is needed. `no address` on the line means the robot is not on a
network; a line with no address at all means a release from before robots broadcast one, and
`duck-btctl wifi status` still reports it.
The SSID is not in the listing — it does not fit in an advertisement. `duck-btctl wifi status` has
it, along with the signal and both addresses.
A robot that has never been renamed calls itself `duck-` plus four characters derived from its
serial, so `duck-c51b`. Either half of a robot reported under two names at once — macOS shows
`radxa-zero3 [duck-c51b]` — works as `--name`.