1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
//! An implementation of Ping, with focus on using little RAM and producing output suitable to a
//! (possibly polling, especially for lack of implemented observations) data retreival method.
//!
//! This works on a static list of potential pingings (always 4, to be configurable when const
//! generic defaults are usable in RIOT's used nightly).
//!
//! This is losely based on the sc_gnrc_icmpv6_echo implementation in RIOT.
//!
//! ## Usage
//!
//! When registered under `/ping` (see [ping_tree] help for why this is essential right now
//! unfortunately), send POST requests with an address to `/ping/`, fetch from the indicated
//! location with GET, and remove the obtained data with DELETE to free up resources again:
//!
//! ```shell
//! $ aiocoap-client 'coap://[2001:db8::1]/ping/' -m POST --content-format 0 --payload 2001:db8::2
//! Location options indicate new resource: /ping/47
//! $ aiocoap-client 'coap://[2001:db8::1]/ping/47' --accept 0
//! Pinging 2001:db8::2. Sending 19 of 20, received 19.
//! $ aiocoap-client 'coap://[2001:db8::1]/ping/47' -m DELETE
//! ```
//!
//! Pings do not measure time (apart from marking a packet as "late" if it's more than 16 seconds
//! behind), and are not configurable in terms of ping interval (which is currently 1 second
//! independent of the arrival of any packets).
//!
//! Rough plans for extensibility include logging of delay as minimum (possibly with time stamps
//! from the network interface), average and maximum (all of which can be done in constant space),
//! finer granularity of time reporting (the current API only measures times in numbers packets
//! sent between then and now), reporting in user-defined bins, and retention of responding
//! addresses (useful in multicast pings). Possibly, those would be mutually exclusive to keep
//! space small (eg. you can get a list of address-count pairs *or* a fine-grained histogram).
//! Further extensions could also set the TTL, or vary it across pings for effectively a traceroute
//! (which would best be combined with the return address retention already proposed for multicast).
//!
//! ## Interface
//!
//! In CoAP resource discovery, a resource behaving like this can be recognized by the interface
//! type `if=tag:chrysn@fsfe.org,2024-09-16:ping`. Any such resource can receive POSTs with
//! content-format application/cbor containing a single IP address (possibly with zone identifier)
//! tagged 54.
//!
//! A successful POST gives the location of the ping job, which should be DELETEd when not used.
//!
//! The job's status can be received in content format application/cbor, whose CDDL is roughly
//!
//! ```cddl
//! ;# include rfc9164

//! job = [address, stop_after, stats]
//! stats = [sent, latest, seen, repeats_in_time, later]
//!
//! address = ip-address-or-prefix ; Target IP address
//! stop_after = uint              ; Number of pings to be sent in total
//!
//! sent = uint                    ; Number of requests that have been sent
//! latest = uint                  ; 16-bit bitfield where the LSB indicates that the most recently
//!                                ; sent request was received (at most `sent` bits are set)
//! seen = uint                    ; Number of responses that have been received in time (this is
//!                                ; always at last as many as there are bits in latest, and more
//!                                ; more than 16 requests have been sent)
//! repeat = uint                  ; Number of duplicates received in time
//! later = uint                   ; Number of responses or duplicates that arrived too late
//! ```
//!
//! Compatible updates to this interface can happen by including items in the top or inner arrays;
//! incompatible updates will use a different interface description.

use core::fmt::Write;

use try_lock::{Locked, TryLock};

use coap_handler_implementations::wkc;
use coap_message::Code;
use coap_message_utils::{option_value::Block2RequestData, Error, OptionsExt};

use crate::minicbor_helpers::IpWithZone;

use riot_wrappers::{
    gnrc::{self, netapi},
    gnrc_pktbuf as pktbuf,
};

/// The path prefix that, where we can not get it from the request, we assume to have been present.
///
/// This represents a big FIXME in the coap-handler ecosystem
const PREFIX: &[&'static str] = &["ping"];

/// Type used for all counts of pings. Makes sense as u16 because that's how wide the sequence
/// number field is (but in principle there shouldn't be anything wrong with going larger and
/// spreading identifier and sequence number into the payload).
type Count = u16;
/// Receive window type. Going for u16 just because it works well alignment-wise when mixed with
/// Count values. (Should work just as well with u128, though).
type Field = u16;

#[derive(Debug, minicbor::Encode)]
// Counters ... maybe more "stats", where the stats implementation may be switchable between
// "accept long delays with little temporal granularity" (bitfield), "high precision but old
// packets are easily discarded" (timestamps), something inbetween (timestamps for short-term),
// plus time stats in a slotted fashion (means, minmax, predefined bins), or also
// just-count-but-store-ip-addresses (for multicast)?
struct PingStats {
    /// Number of requets sent (which is the precise number of ticks received)
    #[n(0)]
    sent: Count,
    /// 16-bit bit field indicating for which request a response has been seen. The least
    /// significant bit represents whether the latest packet has been received, higher bits
    /// indicate older packets.
    ///
    /// (If we wanted to measure time in ticks or any other unit of time, this'd become an array of
    /// Option<send_timestamp>).
    #[n(1)]
    latest: Field,
    /// Number of responses received in time (ie. before going out of `latest`)
    #[n(2)]
    seen_in_time: Count,
    /// Number of responses received in time to be counted again after already having been seen
    #[n(3)]
    repeats_in_time: Count,
    /// Number of responses received after going out of `latest` (may be initials if there was any
    /// packet loss, may be duplicates)
    #[n(4)]
    late: Count,
}

impl PingStats {
    fn new() -> Self {
        Self {
            sent: 0,
            latest: 0,
            seen_in_time: 0,
            repeats_in_time: 0,
            late: 0,
        }
    }

    fn tick(&mut self) {
        self.sent += 1;
        self.latest <<= 1;

        // Not sending the message -- that's up to the PingJob
    }

    fn receive_number(&mut self, number: Count) {
        let bit_position = self.sent.checked_sub(1).and_then(|n| n.checked_sub(number));
        // If this got negative, we're receiving something we didn't request yet, but we'll treat
        // that as "some packet that's not currently in our receive buffer".

        let bit = bit_position.and_then(|n| (1 as Field).checked_shl(n.into()));

        if let Some(bit) = bit {
            if self.latest & bit == 0 {
                self.latest |= bit;
                self.seen_in_time += 1;
            } else {
                self.repeats_in_time += 1;
            }
        } else {
            self.late += 1;
        }
    }
}

#[derive(Debug, minicbor::Encode)]
struct PingJob {
    /// Destination address (initially without any zone identifer)
    #[n(0)]
    address: IpWithZone,
    /// Field that sets this ping apart from other pings between the same hosts (especially when
    /// multicast pinging).
    #[cbor(skip)]
    identifier: u16,

    #[n(1)]
    stop_after: Count,

    #[n(2)]
    stats: PingStats,
}

impl PingJob {
    fn new(stop_after: Count, address: IpWithZone, identifier: u16) -> Self {
        Self {
            address,
            stop_after,
            identifier,
            stats: PingStats::new(),
        }
    }

    fn tick(mut selflock: Locked<Option<Self>>) {
        if let Some(s) = &mut *selflock {
            if s.stats.sent < s.stop_after {
                let counter = s.stats.sent;

                // FIXME: Add a version that increments time for the late-comers but doesn't increment
                // the number of presumably sent packets? (It'd be tempting to just tick on, but then
                // the number of shown-sent packets increases as that's coupled to the window view of
                // the latest packets)
                s.stats.tick();

                PingJob::send(selflock, counter);
            }
        }
    }

    fn received(&mut self, identifier: u16, seqno: u16, _sender: &IpWithZone) {
        if identifier != self.identifier {
            return;
        }
        self.stats.receive_number(seqno);
    }

    // somewhat similar to sc_gnrc_icmpv6_echo.c's _pinger
    fn send(selflock: Locked<Option<Self>>, counter: u16) {
        let s = selflock
            .as_ref()
            .expect("Locked option is checked to be Some in tick");

        let pkt = pktbuf::Pktsnip::icmpv6_echo_build(
            gnrc::icmpv6::EchoType::Request,
            s.identifier,
            counter,
            &[],
        )
        .unwrap();
        let ip = (&s.address.ip).into();
        let mut pkt = pkt
            .ipv6_hdr_build(None, Some(&ip))
            // Could unwrap too, but unlike icmpv6_echo_build this still returns an option (see
            // NotEnoughSpace comment)
            .expect("No space to create IPv6 header buffer");

        if let Some(zone) = s.address.zone {
            if let Some(pid) = riot_wrappers::thread::KernelPID::new(zone.get() as i16) {
                pkt = pkt
                    .netif_hdr_builder()
                    .without_link_layer_addresses()
                    // No need to check this, sock doesn't check it either: It's not dispatched to that
                    // thread directly, but gnrc_netapi_dispatch still goes throug the netreg
                    .with_if_pid(pid)
                    .finish()
                    .expect("No space to create netif header buffer");
            }
        }

        // Just in case sending returns immediately, let's be unlocked already
        drop(selflock);

        // Not checking whether it was actually taken up by anything; there's the silent assumption
        // that when IPv6 is available, there's also any link layer to take it.
        netapi::dispatch_send(
            riot_sys::gnrc_nettype_t_GNRC_NETTYPE_IPV6,
            riot_sys::GNRC_NETREG_DEMUX_CTX_ALL,
            pkt,
        );
    }
}
impl core::fmt::Display for PingJob {
    fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
        writeln!(
            f,
            "Pinging {}. Sending {} of {}, received {}.",
            self.address.ip, self.stats.sent, self.stop_after, self.stats.seen_in_time
        )
    }
}

/// Container for a number of ping jobs
///
/// To make this run, the pool's tick method needs to be called at regular intervals (at least
/// while its `any_active_now` returns true), and any incoming ICMP responses need to be turned
/// over to its `.received` method.
///
/// Instead of calling [`.tick()`] and [`.received()`] manually, the [`.run_on_async()`] infinite
/// async function can be run.
///
/// In parallel to that, the PingPool needs to be registered with a CoAP handler (see [ping_tree]).
#[derive(Default, Debug)]
pub struct PingPool {
    jobs: [TryLock<Option<PingJob>>; 4],
    // Place to store some altering numbers; by not just being the slot index, we can ensure that
    // packets that are around between two jobs will still not wind up in the old ping's count.
    next_id: TryLock<u16>,
}

impl PingPool {
    pub const fn new() -> Self {
        Self {
            jobs: [
                TryLock::new(None),
                TryLock::new(None),
                TryLock::new(None),
                TryLock::new(None),
            ],
            next_id: TryLock::new(0),
        }
    }

    /// Set up a hook for receiving ICMP messages.
    ///
    /// It requires a 'static pool to ease GNRC registrations, and GNRC callback slot that contains
    /// the memory needed for handling the registration. Note that the slot can be crated through a
    /// `static_cell::StaticCell` initialized with `SLOT.init(Default::default())`, and by its own
    /// construction is previously unused. (The static mutable reference is consumed in this
    /// function).
    pub fn register(
        self: &'static PingPool,
        callback_slot: &'static mut gnrc::netreg::callback::Slot<PingCallback>,
    ) {
        gnrc::netreg::callback::register_static(
            callback_slot,
            PingCallback(self),
            gnrc::netreg::FullDemuxContext::new_icmpv6_echo(gnrc::icmpv6::EchoType::Reply),
        );
    }

    /// Periodically send a tick to self, keeping all the sending and expiration activities active.
    ///
    /// ### Caveats
    ///
    /// Currently, this has no means of pausing the tick timer while no pings are pending. This
    /// makes it not really a suitable replacement for the deprecated [`.any_active_now()`]
    /// function, but it can gain that capability once it is added.
    pub async fn tick_on_async(
        self: &PingPool,
        mut delay: impl embedded_hal_async::delay::DelayNs,
    ) {
        // FIXME: Stop ticking when any_active_now goes false, and resume once there is some signal
        // to resume.
        loop {
            delay.delay_ms(1000).await;
            self.tick();
        }
    }

    pub fn tick(&self) {
        for ping in self.jobs.iter() {
            if let Some(ping) = ping.try_lock() {
                PingJob::tick(ping);
            }
            // else we're sorry but something happened concurrently and we skipped a beat
        }
    }

    // Beware this may be registered into a callback that might in theory even execute from an ISR.
    pub fn received(&self, packet: pktbuf::Pktsnip<pktbuf::Shared>) {
        let sender = packet
            .ipv6_get_header()
            .expect("Recieved packet that's not IPv6")
            .src();
        let icmpv6 = packet
            .search_type(riot_sys::gnrc_nettype_t_GNRC_NETTYPE_ICMPV6)
            .expect("Received packet that's not ICMPv6");
        let zone = packet
            .netif_get_header()
            // Not differentiating between "none specified" and "not coming from a netif" (and
            // can't differentiate between "none specified" and "some error in there" because
            // KernelPID just refeuses to use either)
            .and_then(|h| h.if_pid())
            .map(|pid| pid.into());

        let sender = IpWithZone {
            ip: sender.into(),
            zone,
        };

        // It's probably even guaranteed through the stack (and not
        // ostrictly necessary here as the below does index-checks)
        assert!(icmpv6.data.len() >= 8);
        // Funnily, RIOT has ways of building these but none of parsing...
        let identifier = u16::from_be_bytes(icmpv6.data[4..6].try_into().unwrap());
        let seqno = u16::from_be_bytes(icmpv6.data[6..8].try_into().unwrap());

        for ping in self.jobs.iter() {
            if let Some(mut ping) = ping.try_lock() {
                if let Some(ping) = ping.as_mut() {
                    ping.received(identifier, seqno, &sender);
                }
                // else it's just an empty slot
            }
            // else we're sorry but this is just in use
        }
    }

    #[deprecated(
        note = "Use tick_on_async: any_active_now only allows you to retire the timer if you can get an ask-again signal from the network stack at any time something happens around the CoAP handler. (Otherwise, how would you know when to resume ticking?)"
    )]
    pub fn any_active_now(&self) -> bool {
        // locked jobs are assumed to be active for sure
        self.jobs
            .iter()
            .any(|s| s.try_lock().map(|j| j.is_some()).unwrap_or(true))
    }
}

/// Wrapper around a [`&'static PingPool`] that is not constructed publicly, but used as part of
/// the type required to pass in to [`PingPool::register()`].
pub struct PingCallback(&'static PingPool);

impl gnrc::netreg::callback::Callback for PingCallback {
    fn called(
        &self,
        cmd: riot_wrappers::gnrc::netreg::callback::Command,
        snip: riot_wrappers::gnrc_pktbuf::Pktsnip<riot_wrappers::gnrc_pktbuf::Shared>,
    ) {
        if cmd != riot_wrappers::gnrc::netreg::callback::Command::Receive {
            // It's odd because so far it doesn't happen, but there is no guarantee it will
            // never happen -- it could well be that at one point outgoing ping responses *do*
            // turn up here, and we are not interested.
            return;
        }
        self.0.received(snip);
    }
}

/// A view on a PingPool that, when encoded through minicbor, gives bare CRI reference links
struct PingPoolView<'a>(&'a PingPool);

impl<C> minicbor::Encode<C> for PingPoolView<'_> {
    fn encode<W: minicbor::encode::Write>(
        &self,
        e: &mut minicbor::Encoder<W>,
        _ctx: &mut C,
    ) -> Result<(), minicbor::encode::Error<W::Error>> {
        e.begin_array()?;

        let mut buf: heapless::String<5> = heapless::String::new();
        for job in self.0.jobs.iter() {
            buf.clear();
            let job = job
                .try_lock()
                // FIXME can we make that into the same Error::service_unavailable as the rest of
                // such errors?
                .ok_or(minicbor::encode::Error::message(
                    "Data temporarily inaccesible",
                ))?;
            let Some(job) = job.as_ref() else {
                continue;
            };
            write!(buf, "{}", job.identifier).expect("Number does fit");
            // FIXME: Use a CRI crate
            e.array(2)?;
            e.u8(1)?; // Discard 1: Remove the empty path element
            e.array(1)?; // 1 element new path
            e.str(buf.as_str())?;
        }

        e.end()?;
        Ok(())
    }
}

impl core::fmt::Display for PingPoolView<'_> {
    fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
        let mut counted = 0;
        for job in self.0.jobs.iter() {
            let Some(job) = job.try_lock() else {
                // FIXME can we make that into the same Error::service_unavailable as the rest of
                // such errors?
                writeln!(f, "Data temporarily inaccesible")?;
                return Ok(());
            };
            let Some(job) = job.as_ref() else {
                continue;
            };

            writeln!(
                f,
                "./{} ({})",
                job.identifier,
                if job.stop_after == job.stats.sent {
                    "done"
                } else {
                    "active"
                }
            )?;
            counted += 1;
        }
        if counted == 0 {
            writeln!(f, "Nothing active, POST IP address.")?;
        }
        Ok(())
    }
}

// FIXME: This will become prettier the more const things are allowed.
const FORMAT_PLAIN: u16 = {
    let Some(x) = coap_numbers::content_format::from_str("text/plain; charset=utf-8") else {
        panic!()
    };
    x
};
const FORMAT_CBOR: u16 = {
    let Some(x) = coap_numbers::content_format::from_str("application/cbor") else {
        panic!()
    };
    x
};

#[derive(Default)]
enum Accept {
    #[default]
    Cbor,
    PlainText,
}
impl coap_message_utils::option_value::TryFromOption for Accept {
    fn try_from(o: &impl coap_message::MessageOption) -> Option<Self> {
        if o.number() != coap_numbers::option::ACCEPT {
            return None;
        }
        Some(match o.value_uint()? {
            FORMAT_PLAIN => Accept::PlainText,
            FORMAT_CBOR => Accept::Cbor,
            _ => return None,
        })
    }
}

/// A resource tree representing a set of launchable pings.
///
/// See [PingPool] documentation on what else needs to be done with this besides being registered
/// in a CoAP handler.
///
/// Caveat: Due to shortcomings of the Handler trait and/or the tree building, this should really
/// be `.below(&["ping"], ...)`, for the /ping/ prefix is hardcoded in the Location-Path responses.
pub fn ping_tree<'a>(
    pings: &'a PingPool,
) -> impl coap_handler::Handler + coap_handler::Reporting + 'a {
    const ROOT_NAME: &str = "";

    #[derive(Copy, Clone)]
    enum PathState {
        Empty,
        Root,
        Id(u16),
        Derailed,
    }

    use PathState::*;

    impl PathState {
        fn feed(&mut self, segment: &str) {
            *self = match (*self, segment, segment.parse()) {
                (Empty, ROOT_NAME, _) => Root,
                (Empty, _, Ok(n)) => Id(n),
                _ => Derailed,
            };
        }
    }

    struct PingTree<'a> {
        pings: &'a PingPool,
    }

    enum RequestData {
        GetIndex(Accept, Block2RequestData),
        PostOk(u16),
        PostAllFull,
        Get(u16, Accept, Block2RequestData),
        DeleteOk,
    }

    use RequestData::*;
    impl<'a> coap_handler::Handler for PingTree<'a> {
        type RequestData = RequestData;

        type ExtractRequestError = Error;
        type BuildResponseError<M: coap_message::MinimalWritableMessage> = Error;

        fn extract_request_data<M: coap_message::ReadableMessage>(
            &mut self,
            m: &M,
        ) -> Result<Self::RequestData, Error> {
            let mut path = Empty;

            let mut block2 = None;
            let mut content_format: Option<u16> = None;
            let mut accept: Option<Accept> = None;

            use coap_message::MessageOption;
            m.options()
                .take_uri_path(|p| path.feed(p))
                // Careful: This is critical, so handlers that don't use it (or are already erring)
                // need to process it.
                .take_block2(&mut block2)
                .take_into(&mut accept)
                // Elective
                .filter_map(|o| {
                    if o.number() == coap_numbers::option::CONTENT_FORMAT {
                        content_format = o.value_uint();
                        None
                    } else {
                        Some(o)
                    }
                })
                .ignore_elective_others()?;

            match (m.code().into(), path, accept) {
                (_, Empty | Derailed, _) => Err(Error::not_found()),
                (coap_numbers::code::GET, Root, accept) => Ok(GetIndex(
                    accept.unwrap_or_default(),
                    block2.unwrap_or_default(),
                )),
                (coap_numbers::code::POST, Root, None) => {
                    if block2.is_some() {
                        return Err(Error::bad_option(coap_numbers::option::BLOCK2));
                    }

                    let address;

                    match content_format {
                        Some(FORMAT_PLAIN) => {
                            let ip: core::net::Ipv6Addr;
                            ip = core::str::from_utf8(m.payload())
                                .map_err(|ue| Error::bad_request_with_rbep(ue.valid_up_to()))?
                                // FIXME: Can we parse this with zone identifier too? (But we
                                // wouldn't send it out yet)
                                .parse()
                                // AddrParseError sadly has no position
                                .map_err(|_| Error::bad_request())?;

                            address = IpWithZone { ip, zone: None };
                        }
                        Some(FORMAT_CBOR) => {
                            let mut decoder = minicbor::Decoder::new(m.payload());
                            address = decoder.decode().map_err(|e| {
                                if let Some(pos) = e.position() {
                                    Error::bad_request_with_rbep(pos)
                                } else {
                                    Error::bad_request()
                                }
                            })?;
                        }
                        _ => {
                            return Err(Error::bad_option(coap_numbers::option::CONTENT_FORMAT));
                        }
                    }

                    for job in self.pings.jobs.iter() {
                        let mut job = job
                            .try_lock()
                            // If any is locked that could be a candidate, just tell the user to
                            // try again. (It's highly unlikely anyway, it'd only be locked while
                            // processing a tick or incoming data).
                            .ok_or(Error::service_unavailable())?;

                        if job.is_some() {
                            // eventually arrinvg at PostAllFull
                            continue;
                        }

                        let mut next_id = self
                            .pings
                            .next_id
                            .try_lock()
                            // just processing incoming pings or sending some out; come back later
                            .ok_or(Error::service_unavailable())?;

                        let id = *next_id;
                        // We could do something terribly clever here to avoid going to a still-active
                        // slot after 64k jobs have been deleted while an old one is still active ...
                        // not worth it.
                        *next_id = next_id.wrapping_add(1);

                        *job = Some(PingJob::new(20, address, id));
                        // Resisting the temptation to send a tick right away: There's a good code
                        // path to it after the timer triggers, possibly in another thread if Gcoap
                        // is used, so no need to be special here.
                        return Ok(PostOk(id));
                    }

                    Ok(PostAllFull)
                }
                // Not checking if actually available -- might change until the response handler
                // anyway (at least in theory, as we don't hold a lock across)
                (coap_numbers::code::GET, Id(n), accept) => Ok(Get(
                    n,
                    accept.unwrap_or_default(),
                    block2.unwrap_or_default(),
                )),
                (coap_numbers::code::DELETE, Id(n), None) => {
                    if block2.is_some() {
                        return Err(Error::bad_option(coap_numbers::option::BLOCK2));
                    }

                    for slot in self.pings.jobs.iter() {
                        let mut slot = slot.try_lock().ok_or(Error::service_unavailable())?;
                        if slot.as_ref().is_some_and(|s| s.identifier == n) {
                            *slot = None;
                        }
                    }
                    Ok(DeleteOk)
                }
                (_, _, Some(_)) => Err(Error::unsupported_content_format()),
                (_, _, _) => Err(Error::method_not_allowed()),
            }
        }
        fn estimate_length(&mut self, _rd: &Self::RequestData) -> usize {
            // No clue yet, really
            1025
        }
        fn build_response<M: coap_message::MutableWritableMessage>(
            &mut self,
            m: &mut M,
            rd: Self::RequestData,
        ) -> Result<(), Error> {
            match rd {
                PostOk(n) => {
                    m.set_code(
                        coap_numbers::code::CHANGED
                            .try_into()
                            .map_err(|_| ())
                            .unwrap(),
                    );
                    let mut buf: heapless::String<5> = heapless::String::new();
                    write!(buf, "{}", n).expect("Number does fit");
                    // BIG FIXME: How to get that cleanly back? Would the same stripper that
                    // removed the prefix also inject the Location in a special-purpose response
                    // type? But how about any inline data?
                    let location_path = || {
                        coap_message::OptionNumber::new(coap_numbers::option::LOCATION_PATH)
                            .map_err(Error::from_unionerror)
                    };
                    for p in PREFIX {
                        m.add_option(location_path()?, p.as_bytes())
                            .map_err(Error::from_unionerror)?;
                    }
                    m.add_option(location_path()?, buf.as_ref())
                        .map_err(Error::from_unionerror)?;
                }
                Get(i, accept, block2) => {
                    let mut found_job = None;

                    for job in self.pings.jobs.iter() {
                        let job = job.try_lock().ok_or(Error::service_unavailable())?;
                        if job.as_ref().is_some_and(|j| j.identifier == i) {
                            found_job = Some(job);
                            break;
                        }
                    }

                    let Some(found_job) = found_job else {
                        return Err(Error::not_found());
                    };

                    let found_job = found_job
                        .as_ref()
                        .expect("Was just tested tob e is_some_and");

                    m.set_code(coap_numbers::code::CONTENT.try_into().ok().unwrap());
                    use minicbor::encode::Encode;
                    // FIXME: This doesn't send a content-format
                    coap_handler_implementations::helpers::block2_write(block2, m, |win| {
                        match accept {
                            Accept::Cbor => found_job
                                .encode(&mut minicbor::encode::Encoder::new(win), &mut ())
                                .unwrap(),
                            Accept::PlainText => write!(win, "{}", found_job).unwrap(),
                        }
                    });
                }
                GetIndex(accept, block2) => {
                    // FIXME: Say something sensible (but that's rather hard given we'd have to
                    // create a TypeRequestData by masking away the path and sending it
                    // through extract_request_data rather than getting one out from the options).
                    //
                    // Ideally this'd be a CoRAL document, and then we also don't have to worry
                    // about path-absolute references.

                    m.set_code(coap_numbers::code::CONTENT.try_into().ok().unwrap());
                    use minicbor::encode::Encode;
                    let view = PingPoolView(&*self.pings);
                    // FIXME: This doesn't send a content-format
                    coap_handler_implementations::helpers::block2_write(block2, m, |win| {
                        match accept {
                            Accept::Cbor => view
                                .encode(&mut minicbor::encode::Encoder::new(win), &mut ())
                                .unwrap(),
                            Accept::PlainText => write!(win, "{}", view).unwrap(),
                        }
                    });
                }
                PostAllFull => {
                    m.set_code(
                        Code::new(coap_numbers::code::SERVICE_UNAVAILABLE)
                            .map_err(Error::from_unionerror)?,
                    );
                    m.set_payload(b"DELETE some old pings")
                        .map_err(Error::from_unionerror)?;
                }
                DeleteOk => {
                    // For idempotency we just send DELETE always
                    m.set_code(
                        Code::new(coap_numbers::code::DELETED).map_err(Error::from_unionerror)?,
                    );
                }
            };
            Ok(())
        }
    }

    let ping_tree = PingTree { pings };
    wkc::ConstantSingleRecordReport::new_with_path(
        ping_tree,
        &[
            coap_handler::Attribute::Title("Send ICMP pings"),
            coap_handler::Attribute::Interface("tag:chrysn@fsfe.org,2024-09-16:ping"),
        ],
        &[ROOT_NAME],
    )
}