Observing properties¶
Observing (API-38) is how a host gets a device's fast-changing values without polling: it asks once, and the device sends updates on its own, at the rates the host picked, within a byte budget.
The concept¶
The unit is a trait level - the root API, or one ww_impl! trait - not a single property. A host observes a
whole level with one Observe request (RequestKind::Observe, ww_client_server::observe),
picking a mode per +observe property, and from then on the device sends one EventKind::PropertyUpdate per level
whenever anything in it is due, carrying whatever subset of its properties is actually due this time - not every
observed property every time. That update is a sparse struct
(OptionAnyVec): a presence header up front,
then only the present values, so a property that isn't due this time costs roughly one bit, not a byte, let alone
its own message. This is what makes observing cheaper than one StreamData event per value - see
cost vs StreamData below.
#[ww_trait]
trait Motor {
property!(ro+observe speed: f32);
property!(ro+observe current: f32);
property!(rw+observe mode: u8);
property!(rw gain: f32); // not observable
}
Only properties declared ro+observe/rw+observe can be observed; see Properties for declaring
them and the two ways a server can implement a property (get_set/value_on_changed), which also decide how its
observed value is read.
Modes¶
Per property, one of:
| Mode | Sent |
|---|---|
Off |
never |
OnChange { min_us } |
on a change, at most once every min_us |
Periodic { every_us } |
every every_us, on a fixed grid, changed or not |
OnChangeOrBeat { min_us, max_us } |
on a change (at most every min_us), and at least every max_us even without one |
Samples { every_us, max_age_us } |
every sample the device takes (or the first in each every_us), in batches at most max_age_us old; +batched properties only, see Every sample |
A property that starts being observed is always sent once right away, so the host has a value to start from.
Periods and min/max are in microseconds (UNib32), evolvable: a mode a device doesn't know (sent by a newer
host) reads as Unknown(_) and is refused for that entry rather than guessed at.
Budget¶
Each level gets a budget in bytes per second, granted by the device (a host may ask for one, or leave it to the
device); all levels together are capped at ObserveLimits::total_budget - properties' share of the link, next to
reserved streams (stream stores/reliable streams get what's left per
turn). What the device grants and clamps:
- a period or
min/maxbelowObserveLimits::min_period_usis raised to it; - a level's budget is clamped to
ObserveLimits::max_level_budget, and to what's left oftotal_budget; - an
Observerequest is validated entry by entry before anything changes: an unknown index, a property without+observe, a duplicate index or an unknown mode is refused on its own and listed in the answer (ObserveGranted::refused), the rest of the request still applies. A request that doesn't decode, or lists more than 16 refused entries, fails outright (ArgsDesFailed/BadIndex) and changes nothing; an answer that doesn't fit the reply buffer fails withResponseSerFailed, also undone.
The device side¶
Three things the user adds, for an API with any +observe properties: an ObserveState field, handed out by
observe_state(), a poll_properties call in the event loop, and mark_changed_<name>() where the device changes
a value by itself (a value_on_changed write calls it automatically). ww api scaffold prints the field and
observe_state().
struct Server {
observe: api_impl::ObserveState,
// ..
}
impl Server {
fn observe_state(&mut self) -> &mut api_impl::ObserveState {
&mut self.observe
}
fn on_new_measurement(&mut self, current: f32) {
self.current = current;
self.mark_changed_current(); // sent to hosts observing it on change
}
}
let mut server = Server {
observe: api_impl::ObserveState::new(ww_stream::observe::ObserveLimits::DEFAULT),
// ..
};
// event loop, between requests: when something is due, see "The server loop" below
let mut buf = [0u8; 256]; // as big as the biggest update the link carries
server.poll_properties(&mut ww_server.sink(), medium, now_us, &mut buf).await?;
server.poll_streams(&mut ww_server.sink(), now_ms, &mut stream_buf, 4096).await?; // stored streams after
ObserveState holds one ObserveTable<[Slot; N]> per trait level, sized at compile time (N = the level's
property count) - the per-property deadlines, modes and the level's token bucket. The table's code is the same for
every level (it works on ObserveTable<[Slot]>, SRV-23), and so is the loop of poll_properties (SRV-24): plan,
choice, writing the update and its bookkeeping are ww_stream::observe's (plan_level, select, write,
ser_update), one loop over the levels sends from one place, and what the generated code adds per property is
reading its value (size and write arms, and the .await of an async getter). A firmware carries the algorithm
once however many levels its API has. ww_stream::observe::ObserveLimits is
what the device allows every observer: shortest period, default/max budget per level, the total budget, and
whether to send timestamps.
- Due:
Periodicon its grid, never early;OnChangeonce marked changed andminelapsed since its last send;OnChangeOrBeatalso oncemaxpassed without a send. - Values:
get_setproperties are read through their getter when sent;value_on_changedones from their field directly. An unbounded value (e.g. a string) is measured before it is chosen: a sync getter is called for that and again to write it, an async one (handler_model, SRV-21) once, its value kept for both. A getter that fails skips that send. - Token bucket, earliest deadline first: each level has a bucket filled at its granted bytes/s, holding at
most a tenth of a second of it (64 bytes at least).
poll_propertiessends at most one update per level per call: its due properties earliest deadline first, as many as fit the bucket and the buffer. A level short of budget holds: when the due properties don't all fit yet the bucket isn't full, nothing is sent this call - the level waits until they fit or the bucket fills, so it sends fewer, fuller updates (one header each) instead of one value per call with a header of its own (SRV-18; see the motor demo for measured numbers). What's left out keeps its deadline, so a property that slipped goes first next time and none starves. - Missed: a periodic property counts one missed send per whole period it fell behind; an on-change one counts
once it was held back or left out for
minor longer. Held back for less, it's only late - polling often doesn't inflate the count. The count travels in the next update'smissed. - Timestamps:
tis the device's microsecond clock as a wrappingu32(Second { Micro, U32 }, 5 bytes on the wire), wrapping every ~71.6 minutes; the host unwraps it into aU64per level, by the same rule as stream times (timestamps). Only sent whenObserveLimits::timestampsis set. - Limits: a level inside a resource array can't be observed yet (
OperationNotSupported, the bound of its indices isn't known yet, API-29); nor can a+observeproperty that is itself an array or#[optional]. Observing stays set while the link is down;ObserveState::reset()(e.g. on a fresh connection) stops it all.
The server loop¶
A device doesn't poll observed properties on a fixed tick (SRV-25): ObserveState::next_wake(now_us) says when
poll_properties next has something to do, and the server task sleeps until then, or until a request comes or
something outside the server changes. Nothing observed, or nothing that comes due without a change: None, no
wake at all.
- When: the periodic grid,
last + minof a property marked changed,last + maxof a beat, at once for a property just observed or one left out of the last update; a level short of budget no earlier than its bucket has the bytes its held update needs; a batch of samples when it is sealed or as old as the host allows. - Changes from outside the server state:
ObserveSignal, generated next toObserveState, is astaticany task or interrupt marks a property changed through (SIGNAL.mark_changed_<name>(): a bit and a wake, lock-free).ObserveState::with_signal(&SIGNAL)letspoll_propertiesandnext_waketake those changes; the server task waits onSIGNAL.wait().SIGNAL.wake()wakes it for anything else (a deferred answer to send). - No lost wake: a mark sets its bit and a flag, then wakes the task;
wait()registers the task, then takes the flag. A change that comes after the lastnext_wakeand before the sleep makes the nextwait()return at once. Changes made by handlers (self.mark_changed_<name>(), avalue_on_changedwrite) need no signal: the server task is the one running, and its nextnext_wakesees them. - Samples:
<Level>Sampler::with_wake(SIGNAL.as_wake())and<Level>Writer::with_wake(..)(cells) wake the server task when a batch gets its first row (its age starts when a poll sees it) and when it is full or sealed. Without, the level's batches are only looked at when something else wakes the task.
static OBSERVE: api_impl::ObserveSignal = api_impl::ObserveSignal::new();
let observe = State::init(STATE.uninit(), ObserveLimits::DEFAULT, batches);
observe.with_signal(&OBSERVE);
let mut adc_writer = METER_CELLS.writer().unwrap().with_wake(OBSERVE.as_wake()); // to the ADC task
loop {
let now_us = ww_server.now().as_micros();
let next = state.observe.next_wake(now_us).map(Instant::from_micros);
match select(ww_server.wait_until(next), OBSERVE.wait()).await {
Either::First(ready) => {
if let Some(event) = ww_server.handle(ready, &mut state).await { /* LinkEvent::Up / Down */ }
}
Either::Second(()) => {}
}
let now_us = ww_server.now().as_micros();
if ww_server.is_up() && state.observe.next_wake(now_us).is_some_and(|at| at <= now_us) {
state.poll_properties(&mut ww_server.sink(), medium, now_us, &mut buf, scratch).await?;
}
}
// elsewhere, in another task or an interrupt
OBSERVE.mark_changed_plug_vbus();
ww_device::Server::wait_until(at) is wait() that also returns when at comes (Ready::is_due(), nothing for
handle to do), on the server's own clock. Polling only at these times sends the same updates at the same times as
polling every microsecond (ww_stream's next_wake_sends_what_polling_does); tests/observe checks that an
event-driven device polls only on the grid and sends a change from another thread at once.
The host side¶
Each trait level with +observe properties gets observe(cfg) on its generated client (the root client, or
device.motor()); each +observe property also gets observe_<name>(mode). Both return a PreparedObserve,
built further with .budget(bytes_per_s), then .subscribe()/.subscribe_blocking() to get an Observer:
use wire_weaver_client::observe::{on_change, on_change_or_beat, periodic};
use api_client::motor_api_motor::MotorObserve; // in the level's client module (`<api crate>_<trait>`)
let ms = Duration::from_millis(1);
let mut motor = device
.observe(MotorObserve::default().speed(periodic(10 * ms)).current(on_change(ms)))
.budget(20_000)
.subscribe()
.await?;
let granted = motor.granted().unwrap(); // Granted { modes: MotorObserve, budget, refused }
while let Ok(u) = motor.recv().await { // Observed { value: MotorUpdate, t, missed, received }
if let Some(speed) = u.value.speed { /* .. */ }
}
motor.with_latest(|l| l.speed.as_ref().map(|s| (s.value, s.received)));
let mut mode = device.observe_mode(on_change(ms)).subscribe().await?; // Observer of u8 values
mode.configure(MotorObserve::default().mode(periodic(100 * ms))).await?;
- Types:
<Trait>Update(the level'sPropertyUpdatedata, oneOptionper+observeproperty),<Trait>Observe(oneOption<ObserveMode>per property, used both to ask and asGranted::modes), and<Trait>Latest(the latest-value cache: aSample { value, received, t }per property). - One subscription per level: every observer of a level asks for its own settings; the host merges them
(shortest
min/period, biggest budget per property) into oneObservesent to the device, resent when an observer comes, callsconfigure(..), or goes -Offonce the last one is gone. Every observer of a level sees every update of it; a property observer only the updates that carry its property. Settings apply to all observers of a level together, so one may get updates more often than it asked for. - Reconnects: observers outlive
disconnect().keep_streams();resume_streams()resubscribes every level on the next connection (the device may or may not still have the old state) and observers seeResumed. A link that's down for good ends them. - Old devices: a device whose API model predates
ww_client_server0.2 can't observe;.subscribe()fails withError::Unsupported. - Limits: like the device side, a level inside a resource array has no
observe()yet. Absolute paths only - a trait client (client = "trait_client") observes once it has a base path (attachment()).
Every sample (+batched)¶
The modes above send a property's current value, at most once per update. A value that changes faster than
updates can go - a motor current sampled in a 25 kHz control loop - loses everything in between. A +batched
property (API-39) can also be observed in Samples mode: the device keeps every sample it takes and sends them as
compressed Series columns in the level's update: a Series<T> in each property's slot, and
one Series<Second> of the sample times for the whole update (sample_times, next to its t), since a level's
samples are taken together.
#[ww_trait]
trait Foc {
property!(ro+observe+batched i_a: Ampere);
property!(ro+observe+batched i_b: Ampere);
property!(ro+observe duty: f32); // observed as before
}
+batched comes after +observe (alone it is an error). The property's slot in <Trait>Update becomes a
Batched<T>: One(T), one value as in the other modes, or Samples(..), a batch of samples. A host that doesn't
ask for Samples gets One values from the getter, as for any +observe property.
Device side¶
Next to the getter, the device feeds samples where it takes them. Codegen generates, per level with +batched
properties, a <Level>Sampler with sample(t, <Trait>Samples { .. }): one value per +batched property of the
level and their common time. It is O(1) and takes no lock: a gate per level (one atomic load) says whether a host
wants this sample (any property of the level in Samples mode, and the sample on the level's grid), and the row,
the time and every value, goes into the level's batch being filled. Encoding happens later, in poll_properties,
one column at a time: the time once, then each property a host observes in Samples mode.
let mut server = Server {
observe: api_impl::ObserveState::new(
ObserveLimits::DEFAULT,
api_impl::ObserveBatches::new(ww_stream::batch::DEFAULT_POLICY),
),
// ..
};
// in the control loop, every tick
server.observe.samplers().root.sample(
Second::micro(now_us),
api_impl::FocSamples { i_a, i_b },
);
// event loop: encodes sealed batches, then sends updates while batches wait; the scratch holds one column of a
// batch: the biggest of `Second` and the `+batched` property types, `OBSERVE_BATCH` of them
const SB: usize = ww_stream::batch::scratch_bytes::<Second>(api_impl::OBSERVE_BATCH);
let mut scratch = Box::new(ww_stream::batch::Scratch::<SB>::new());
server.poll_properties(&mut ww_server.sink(), medium, now_us, &mut buf, &mut scratch).await?;
To sample from an interrupt or another thread, ObserveBatches::split() hands out the samplers (Send, moved into
the interrupt) and the consumers, which the ObserveState takes instead (ObserveState::new(limits, consumers),
the batches in a static). ww api scaffold writes the observe_batch argument and says how to build the state.
On a firmware, build the batches and the state where they stay: ObserveBatches::init and ObserveState::init
write them into statics in place, every store, table and outbox where it lives, with a few dozen bytes of stack
(8 B at opt-level = 3, 72 B at z for the motor demo's 21 FOC values on a Cortex-M4F). new returns them by
value, and building them that way takes a stack frame of a few times their size (31.6 KB for the same level, more
than a small MCU has). The server then keeps a &'static mut to the state:
type State = api_impl::ObserveState<api_impl::ObserveBatchConsumers<'static>>;
static BATCHES: StaticCell<api_impl::ObserveBatches> = StaticCell::new();
static STATE: StaticCell<State> = StaticCell::new();
let batches = api_impl::ObserveBatches::init(BATCHES.uninit(), ww_stream::batch::DEFAULT_POLICY);
let (samplers, consumers) = batches.split(); // samplers.root to the interrupt
let observe: &'static mut State = State::init(STATE.uninit(), ObserveLimits::DEFAULT, consumers);
- Batches: each level with
+batchedproperties has two stores ofobserve_batch = Nrows (codegen argument, default 32), a row being onesample()call's time and values, kept column by column: the sampler fills one store while the event loop encodes the other. RAM is 2 x N x (the time and every value): for 21 FOC currents and voltages at N = 32 about 8.4 KB. A batch is sealed when it is full, when its worst case reaches what fits one update, or when it is as old asmax_age_us. A burst of more than two batches between twopoll_propertiescalls loses rows: a sample of each observed property per row, counted in the next update'smissed. - Outbox: a sealed batch is encoded into the level's outbox (
observe_outbox = Nbytes, default 4096) as one unit, the time column and the column of each property inSamplesmode, and sent from there whole, in one update, as budget allows, more than one update per call (at mostMAX_UPDATES_PER_POLL). When the outbox is full or the budget can't keep up, the oldest batches go and count asmissed. - Time: the samples of an update share its
sample_times: everySamplesslot has as many samples as it has times, sampleitaken at timei. An update that carries only batches has no headt, one withOnevalues or plain properties has it as before. Rows carry every value of the level, so a property switched intoSamplesalso gets the rows not sent yet, each at its time; one switched out leaves the waiting batches. - Limits:
Samplesis refused for a property without+batched(ModeNotSupported);max_age_usis clamped toObserveLimits::samples_max_age_us(1 ms to 10 s by default);every_usdecimates on the device's grid (the first sample in eachevery_us), 0 is every sample, and is raised toObserveLimits::samples_min_every_us(0, every sample, by default). TheSamplesentries of a level share one grid and one age, so they get the finestevery_usand the shortestmax_age_usany of them asked for: the grant says so, a host that asked for less gets more. Every sample taken is encoded, also when the budget then drops its batch, so on an MCU set that floor from the table below: without it any host can ask for more encoding than the CPU has. A property type that borrows (bytes, a string, a vector) can't be+batched.
Cells: sampling from an interrupt¶
sample(t, FocSamples { .. }) takes the row as one value: the interrupt builds the whole <Trait>Samples on its
stack and the sampler copies it into the batch. For a level sampled in an interrupt, ww_codegen!(..,
observe_cells = "root_foc") (the level's field name in ObserveState, several separated by commas) puts it on
observe cells instead: a ring of atomics the interrupt writes in place, each value where it is computed, and
poll_properties reads. The API and the wire don't change, so this is the device's choice per level, and a host
can't tell which one a device uses.
// ww_codegen!(motor_demo_api::MotorDemo for Device, server = true, .., observe_cells = "root_foc")
static FOC: api_impl::RootFocCells<32> = api_impl::RootFocCells::new(); // const fn, a ring of 32 rows
static BATCHES: StaticCell<api_impl::ObserveBatches<'static>> = StaticCell::new();
// the state borrows the batches in their static
type State = api_impl::ObserveState<&'static mut api_impl::ObserveBatches<'static>>;
static STATE: StaticCell<State> = StaticCell::new();
let batches = api_impl::ObserveBatches::init(
BATCHES.uninit(),
ww_stream::batch::DEFAULT_POLICY,
FOC.reader().unwrap(),
);
let mut server = Server {
observe: State::init(STATE.uninit(), ObserveLimits::DEFAULT, batches), // a `&'static mut State`
// ..
};
// the interrupt keeps the writer (taken once)
let mut w = FOC.writer().unwrap();
// .. every PWM period, where the values are computed
w.i_a(Ampere::milli(ia));
w.i_b(Ampere::milli(ib));
// .. one setter per `+batched` property of the level
w.commit(Second::micro(now_us));
- Writer:
<Level>Cells::writer()hands out the one<Level>Writer(Send, notSync, given back on drop). Each setter is a few relaxed stores into the row being written;commit(t)publishes the row when a host observes the level inSamplesmode andtis on its grid (the gatesample()checks), between two release fences: no lock, no read-modify-write, nothing on the stack but the values. A debug build panics atcommitwhen a setter of the row wasn't called; a release build pays nothing for that. With nobody observing, the setters still store: put them (or the expensive values) underif w.is_sampling() { .. }. - Reader:
<Level>Cells::reader()goes toObserveBatches::initornew(one per cells level, in the order of the levels), which then borrow the cells:ObserveBatches<'static>for astatic, orww_stream::batch::CellsReader::shared(arc)for cells in anArconstd. Eachpoll_propertiescopies the rows committed since the last one into one staging batch, the time and the observed properties' columns only, then encodes and sends it as the sampler's batches are: the same columns, the same bytes. A property observed while rows are staged gets its column copied for them from the ring, so it has the batch's earlier samples as withsample(); if the interrupt wrote over those rows meanwhile, they go without it and the next batch has it. - Ring size:
Rrows (<Level>Cells<R>) hold what the interrupt commits between twopoll_propertiescalls;R - 1can wait. A row the writer writes over before it is read (the ring lapped) is dropped and counted inmissed, never sent with values of two rows. RAM isRrows plus one batch ofobserve_batchrows, about what the sampler's two batches take (for the motor demo's 21 FOC values,R=observe_batch= 32: 8.6 KB). - Under backpressure: while a sealed batch waits for budget or room, the sampler fills its second batch and
loses the rows past it; the cells keep up to
R - 1rows in the ring and send them later, so they lose fewer. When nothing waits, both send the same bytes. - Types: the property types need
SeriesCells(#[derive_shrink_wrap(.., series(cells))], seeSeries);ww_siquantities, numbers andboolhave it. - Cost: on an STM32H725 a row of the 21 FOC values takes 354 cycles at
opt-level = "z"against 412 throughsample(), 328 against 419 atopt-level = 3, and 44-52 B of stack against 108-116 B; the copy into the staging batch moves topoll_properties. See Batching: observe cells, measured.
ww api scaffold --observe-cells root_foc writes the static, the ObserveState type and the ww_codegen!
argument.
Host side¶
<Trait>Observe::samples(max_age) puts every +batched property of the level into Samples mode
(observe::samples(max_age) / samples_every(every, max_age) for one property). A property observer gets one
Observed per sample, each with its own device time, whether the device sent one value or a batch; a level
observer gets BatchedOwned<T> slots and Observed::sample_times, unwrapped like t (BatchedOwned::into_samples
pairs them, a count that doesn't match is an error), and <Trait>Latest keeps the newest sample. An update whose
Samples slots don't match its sample_times is malformed: a warning, its samples left out. Observers of one level
merge into Samples if any of them asks for it.
use wire_weaver_client::observe::samples;
let mut i_a = device.foc().observe_i_a(samples(Duration::from_millis(5))).subscribe().await?;
while let Ok(s) = i_a.recv().await {
println!("{:?} {:?}", s.t, s.value); // every sample, oldest first
}
The dynamic client decodes the same slots from introspection (DynObserved::samples), ww observe --samples MS
prints one line per sample, and Python gets them per update or as numpy columns
(recv_batch()).
Which rates fit an MCU¶
sample() itself is a few loads and stores; the cost is encoding, and it scales with how many properties and how
fast they're sampled - see Batching: which rates fit an MCU for the measured
instructions/cycles-per-sample table (Cortex-M3/M4F/M7) and what it means for a real device. So on an MCU observe
a few properties at a few kHz, or decimate (every_us); full-rate observing of a whole control loop is for the
simulator and the host (motor demo), where it costs about 1 MB/s of link with integer
storage.
Wire format¶
EventKind::PropertyUpdate { path, t, missed, data } is one level's update. data is the <Trait>Update
sparse struct: an OptionAnyVec presence header (the smallest of four modes - all, dense bits, two-level bits,
or an index list, picked by the writer - see OptionAnyVec
for the exact bit layout), then only the present properties' values in slot order, no padding between them. Slots
are the level's resource indices, methods and streams included as gaps that are never present.
A +batched property's slot is a Batched<T>: a nibble tag, then T for One (tag 0), or for Samples (tag 1)
padding to the next byte and a Series<T> to the end of the slot. The event's sample_times (after missed, an
Option<Series<Second>>) is present whenever a Samples slot is, with one time per sample of each. In
introspection such a property is batched: Some(SharedClock).
Slot order matters for size: a bool property takes one bit, but a byte-or-wider type aligns itself to a byte as
always, so a bool between two f32s still costs a byte of padding around it. The sparse worked
example shows this byte-for-byte (speed: Option<u16> and
on: Option<bool> present: 17 c6 dc 05 4c 00 00 c0 3f 81) - group the sub-byte (bool) properties of a level
together in the trait if the savings matter, so their bits share one byte instead of forcing alignment around
them.
The event itself (ser_property_update_event) adds a small fixed header on top of the update's own bytes: with a
device timestamp it's 10 bytes (sequence, result flag, the level's path, t as a u32), 4 bytes without t - see
the motor demo's measured numbers for what this costs on a real update of
many properties.
Cost vs StreamData¶
Sending the same values one at a time as StreamData events (stream_data_ser().<name>_send(..), see
Before observe requests) costs a separate event header per value instead
of one header per level per update. The motor demo measures this on a real
controller with 52 observed properties: about 42% less bytes/s observing a level than sending the same values
as StreamData, growing with how many properties share one update (a single current, a ww_si::Ampere of mA as
I32, 5 bytes, already takes 9-10 bytes alone as StreamData; an update of 21 such values as one PropertyUpdate
takes 117 bytes, 5.6 bytes per value). A level
short of its budget that holds for fuller updates (see above) widens that gap further: the
same page measures 2.7x more values per byte once updates
stop being sent one value at a time.
Walk-through: the motor demo¶
The motor demo (code in motor_sim) observes a simulated FOC motor controller's ~50 properties
across three trait levels (one per rate tier: 25 kHz, 1 kHz, 50 Hz), each tier as one observe subscription at
whatever rate a host asks for, within a budget. It's the reference for everything on this page in a real API:
declaring +observe properties by tier, wiring ObserveState/poll_properties/mark_changed_<name> on the
device, observing tiers at chosen rates and a budget from the CLI, and the measured bytes/s numbers above.
From the CLI and Python¶
Observing also works from introspection alone, no generated code needed (TOOL-11): DynResource's
observable_properties() lists a level's +observe properties (resource index, name, type), and
observe(cmd, entries)/observe_blocking(..) (entries: BTreeMap<u32, ObserveMode>) build a
wire_weaver_client::DynObserver that decodes each update's sparse OptionAnyVec by resource index instead of a
generated struct's fields - reusing the same Level<U>/Observer<U, T> machinery as the generated client
unchanged: one device subscription per level, t unwrapped, reconnect/resume included. Each DynObserved carries
values (present properties by name, in resource-index order), t, missed, received, and data_len (the
update's raw byte length, e.g. for measuring link bytes/s).
ww observe <path> [--rate MS] uses it directly:
$ ww observe motor --rate 10
observing motor (3 properties): speed, current, position
budget: 20.0 kB/s
Ctrl+C to stop
{speed: 1500, current: 3, position: 1024} rate 98.2/s link 982/s
and wire_weaver_py's Node.observe(cfg) iterates the same updates as dicts:
m = dev.motor
m.observable_properties() # {"speed": "u32", "current": "u32", "position": "u32"}
with m.observe({"speed": 10, "current": None}) as obs: # speed periodically every 10ms, current on change
for u in obs:
print(u["values"], u["t"], u["missed"])
Both ask for every observable property on-change without an explicit rate/cfg, and share one device subscription
per level with any generated-client observers of the same level.
+batched properties come sample by sample (DynObserved::samples, ww observe --samples MS, Python's
{"name": {"samples": max_age_ms}}), see Every sample.