File Coverage

blib/lib/Device/Serial/SLuRM.pm
Criterion Covered Total %
statement 176 183 96.1
branch 46 66 69.7
condition 12 21 57.1
subroutine 25 26 96.1
pod 7 8 87.5
total 266 304 87.5


line stmt bran cond sub pod time code
1             # You may distribute under the terms of either the GNU General Public License
2             # or the Artistic License (the same terms as Perl itself)
3             #
4             # (C) Paul Evans, 2022 -- leonerd@leonerd.org.uk
5              
6 6     6   505454 use v5.26;
  6         31  
7 6     6   3012 use Object::Pad 0.57 ':experimental(init_expr)';
  6         48492  
  6         25  
8              
9             package Device::Serial::SLuRM 0.02;
10             class Device::Serial::SLuRM;
11              
12 6     6   2296 use Carp;
  6         21  
  6         285  
13              
14 6     6   2789 use Syntax::Keyword::Match;
  6         4068  
  6         25  
15              
16 6     6   830 use Future::AsyncAwait;
  6         14457  
  6         30  
17 6     6   2869 use Future::Buffer 0.03;
  6         10610  
  6         188  
18 6     6   619 use Future::IO;
  6         12403  
  6         190  
19              
20 6     6   2711 use Digest::CRC qw( crc8 );
  6         14153  
  6         564  
21              
22 6   50 6   47 use constant DEBUG => $ENV{SLURM_DEBUG} // 0;
  6         12  
  6         533  
23              
24             use constant {
25 6         29903 SLURM_PKTCTRL_META => 0x00,
26             SLURM_PKTCTRL_META_RESET => 0x01,
27             SLURM_PKTCTRL_META_RESETACK => 0x02,
28              
29             SLURM_PKTCTRL_NOTIFY => 0x10,
30              
31             SLURM_PKTCTRL_REQUEST => 0x30,
32              
33             SLURM_PKTCTRL_RESPONSE => 0xB0,
34             SLURM_PKTCTRL_ACK => 0xC0,
35             SLURM_PKTCTRL_ERR => 0xE0,
36 6     6   34 };
  6         11  
37              
38             =encoding UTF-8
39              
40             =head1 NAME
41              
42             C - communicate the SLµRM protocol over a serial port
43              
44             =head1 SYNOPSIS
45              
46             use v5;36;
47             use Device::Serial::SLuRM;
48              
49             my $slurm = Device::Serial::SLuRM->new(
50             dev => "/dev/ttyUSB0",
51             baud => 19200,
52             );
53              
54             $slurm->run(
55             on_notify => sub ($payload) {
56             printf "NOTIFY: %v02X\n", $payload;
57             }
58             )->await;
59              
60             =head1 DESCRIPTION
61              
62             This module provides a L-based interface for communicating with
63             a peer device on a serial port (or similar device handle) which talks the
64             SLµRM messaging protocol. It supports sending and receiving of NOTIFY
65             packets, and sending of REQUEST packets that receive a RESPONSE.
66              
67             It currently does not support receiving REQUESTs, though this could be added
68             relatively easily.
69              
70             =head2 SLµRM
71              
72             SLµRM ("Serial Link Microcontroller Reliable Messaging") is a simple
73             bidirectional communication protocol for adding reliable message framing and
74             request/response semantics to byte-based data links (such as asynchronous
75             serial ports), which may themselves be somewhat unreliable. SLµRM can tolerate
76             bytes arriving corrupted or going missing altogether, or additional noise
77             bytes being received, while still maintaining a reliable bidirectional flow of
78             messages. There are two main kinds of message flows - NOTIFYs and REQUESTs. In
79             all cases, packet payloads can be of a variable length (including zero bytes),
80             and the protocol itself does not put semantic meaning on those bytes - they
81             are free for the application to use as required.
82              
83             A NOTIFY message is a simple notification from one peer to the other, that
84             does not yield a response.
85              
86             A REQUEST message carries typically some sort of command instruction, to which
87             the peer should respond with a RESPONSE or ERR packet. Replies to a REQUEST
88             message do not have to be sent sequentially.
89              
90             The F directory of this distribution contains more detailed protocol
91             documentation which may be useful for writing other implementations.
92              
93             The F directory of this distribution contains a reference
94             implementation in C for 8-bit microcontrollers, such as AVR ATtiny and ATmega
95             chips.
96              
97             =cut
98              
99             =head2 Metrics
100              
101             If L is available, this module additionally provides metrics
102             under the namespace prefix of C. The following metrics are provided:
103              
104             =over 4
105              
106             =item discards
107              
108             An unlabelled counter tracking the number of times a received packet is
109             discarded due to failing CRC check.
110              
111             =item packets_received
112              
113             A counter, labelled by packet type, tracking the number of packets received of
114             each type.
115              
116             =item packets_sent
117              
118             A counter, labelled by packet type, tracking the number of packets sent of
119             each type.
120              
121             =item retransmits
122              
123             An unlabelled counter tracking the number of times a (REQUEST) packet had to
124             be retransmitted after the initial one timed out.
125              
126             =item timeouts
127              
128             An unlabelled counter tracking the number of times a request transaction was
129             abandoned entirely due to a timeout. This does I count transactions that
130             eventually succeeded after intermediate timeouts and retransmissions.
131              
132             =back
133              
134             =cut
135              
136             # Metrics support is entirely optional
137             our $METRICS;
138             eval {
139             require Metrics::Any and Metrics::Any->VERSION( '0.05' ) and
140             Metrics::Any->import( '$METRICS', name_prefix => [ 'slurm' ] );
141             };
142              
143             my %PKTTYPE_NAME;
144              
145             if( defined $METRICS ) {
146             $METRICS->make_counter( discards =>
147             description => "Number of received packets discarded due to CRC check",
148             );
149              
150             $METRICS->make_counter( packets_received =>
151             description => "Number of SLµRM packets received, by type",
152             labels => [qw( type )],
153             );
154              
155             $METRICS->make_counter( packets_sent =>
156             description => "Number of SLµRM packets sent, by type",
157             labels => [qw( type )],
158             );
159              
160             $METRICS->make_distribution( request_retries =>
161             description => "How many requests eventually succeeded after a given number of retries",
162             buckets => [ 0 .. 2 ],
163             );
164             $METRICS->make_counter( retransmits =>
165             description => "Number of retransmits of packets",
166             );
167              
168             $METRICS->make_counter( timeouts =>
169             description => "Number of transactions that were abandoned due to eventual timeout",
170             );
171              
172             %PKTTYPE_NAME = map { __PACKAGE__->can( "SLURM_PKTCTRL_$_" )->() => $_ }
173             qw( META NOTIFY REQUEST RESPONSE ERR ACK );
174              
175             # Keep prometheus increase() happy by initialising all the counters to zero
176             $METRICS->inc_counter_by( discards => 0 );
177             $METRICS->inc_counter_by( packets_received => 0, { type => $_ } ) for values %PKTTYPE_NAME;
178             $METRICS->inc_counter_by( packets_sent => 0, { type => $_ } ) for values %PKTTYPE_NAME;
179             $METRICS->inc_counter_by( retransmits => 0 );
180             $METRICS->inc_counter_by( timeouts => 0 );
181             }
182              
183             =head1 PARAMETERS
184              
185             =head2 dev
186              
187             dev => PATH
188              
189             Path to the F device node representing the serial port used for this
190             communication. This will be opened via L and configured into the
191             appropriate mode and baud rate.
192              
193             =head2 baud
194              
195             baud => NUM
196              
197             Optional baud rate to set for communication when opening a device node.
198              
199             SLµRM does not specify a particular rate, but a default value of 115.2k will
200             apply if left unspecified.
201              
202             =head2 fh
203              
204             fh => IO
205              
206             An IO handle directly to the the serial port device to be used for reading and
207             writing. It will be assumed to be set up correctly; no further setup will be
208             performed.
209              
210             Either C or C are required.
211              
212             =head2 retransmit_delay
213              
214             retransmit_delay => NUM
215              
216             Optional delay in seconds to wait after a non-response of a REQUEST packet
217             before sending it again. A default of 50msec (0.05) will apply if not
218             specified.
219              
220             Applications that transfer large amounts of data over slow links, or for which
221             responding to a command may take a long time, should increase this value.
222              
223             =head2 retransmit_count
224              
225             retransmit_count => NUM
226              
227             Optional number of additional attempts to try sending REQUEST packets before
228             giving up entirely. A default of 2 will apply if not specified (thus each
229             C method will make up to 3 attempts).
230              
231             =cut
232              
233             field $_fh :param { undef };
234              
235             ADJUST ( $params )
236             {
237             if( defined $_fh ) {
238             # fine
239             }
240             elsif( exists $params->{dev} ) {
241             my $dev = delete $params->{dev};
242             my $baud = delete $params->{baud} // 115200; # TODO default baud?
243              
244             require IO::Termios;
245              
246             $_fh = IO::Termios->open( $dev, "$baud,8,n,1" ) or
247             croak "Cannot open device $dev - $!";
248              
249             $_fh->cfmakeraw;
250             }
251             else {
252             croak "Require either a 'dev' or 'fh' parameter";
253             }
254             }
255              
256             field $_retransmit_delay :param { 0.05 };
257             field $_retransmit_count :param { 2 };
258              
259             field $_on_notify;
260              
261             field $_did_reset;
262              
263             field $_seqno_tx { 0 };
264 1     1   4522 field $_seqno_rx :reader(_seqno_rx); # :reader just for unit-test purposes
  1         7  
265              
266             =head1 METHODS
267              
268             =cut
269              
270             =head2 recv_packet
271              
272             ( $pktctrl, $payload ) = await $slurm->recv_packet;
273              
274             Waits for and returns the next packet to be received from the serial port.
275              
276             =cut
277              
278             field $_recv_buffer;
279              
280             field $_next_resetack_f;
281              
282             async method recv_packet
283 42         100 {
284             $_recv_buffer //= Future::Buffer->new(
285 35     35   1043 fill => sub { Future::IO->sysread( $_fh, 8192 ) },
286 42   66     129 );
287              
288             PACKET: {
289 42         120 await $_recv_buffer->read_until( qr/\x55/ );
  45         194  
290              
291 33         28642 my ( $pktctrl, $len ) = unpack "C C", my $pkt = await $_recv_buffer->read_exactly( 3 );
292              
293 33 100       1568 if( crc8( $pkt ) != 0 ) {
294             # Header checksum failed
295 1 50       34 $METRICS and
296             $METRICS->inc_counter( discards => );
297              
298 1 50       4 $_recv_buffer->unread( $pkt ) if $pkt =~ m/\x55/;
299 1         3 redo PACKET;
300             }
301              
302 32         1002 $pkt .= await $_recv_buffer->read_exactly( $len + 1 );
303              
304 32 100       2440 if( crc8( $pkt ) != 0 ) {
305             # Body checksum failed
306 2 50       50 $METRICS and
307             $METRICS->inc_counter( discards => );
308              
309 2 100       9 $_recv_buffer->unread( $pkt ) if $pkt =~ m/\x55/;
310 2         28 redo PACKET;
311             }
312              
313 30         767 my $payload = substr( $pkt, 3, $len );
314              
315 30         50 printf STDERR "SLuRM <-RX {%02X/%v02X}\n", $pktctrl, $payload
316             if DEBUG > 1;
317              
318             $METRICS and
319 30 50 0     61 $METRICS->inc_counter( packets_received => { type => $PKTTYPE_NAME{ $pktctrl & 0xF0 } // "UNKNOWN" } );
320              
321 30         174 return $pktctrl, $payload;
322             }
323 42     42 1 20462 }
324              
325             field @_pending_slots; # [$seqno] = { payload, response_f }
326             field $_run_f;
327              
328             async method _run
329 12         24 {
330 12         18 while(1) {
331 31         6924 my ( $pktctrl, $payload ) = await $self->recv_packet;
332 19         1046 my $seqno = $pktctrl & 0x0F;
333 19         31 $pktctrl &= 0xF0;
334              
335 19 100       39 if( $pktctrl == SLURM_PKTCTRL_META ) {
336 4 50 66     21 if( $seqno == SLURM_PKTCTRL_META_RESET or
337             $seqno == SLURM_PKTCTRL_META_RESETACK ) {
338 4         14 ( $_seqno_rx ) = unpack "C", $payload;
339              
340 4 100       13 if( $seqno == SLURM_PKTCTRL_META_RESET ) {
341 1         4 await $self->send_packet( SLURM_PKTCTRL_META_RESETACK, pack "C", $_seqno_tx );
342             }
343             else {
344 3 50       14 $_next_resetack_f->done if $_next_resetack_f;
345             }
346             }
347             else {
348 0         0 warn sprintf "No idea what to do with pktctrl(meta) = %02X\n", $seqno;
349             }
350              
351 4         2553 next;
352             }
353              
354 15         22 my $is_dup;
355 15 100       33 if( !( $pktctrl & 0x80 ) ) {
356 10 50       19 if( defined $_seqno_rx ) {
357 10         14 my $seqdiff = $seqno - $_seqno_rx;
358 10 100       19 $seqdiff += 16 if $seqdiff < 0;
359 10   100     29 $is_dup = !$seqdiff || $seqdiff > 8; # suppress duplicates / backsteps
360             }
361              
362 10         15 $_seqno_rx = $seqno;
363             }
364              
365             match( $pktctrl : == ) {
366             case( SLURM_PKTCTRL_NOTIFY ) {
367 10 100       17 next if $is_dup;
368              
369 8         9 printf STDERR "SLuRM rx-NOTIFY(%d): %v02X\n", $seqno, $payload
370             if DEBUG;
371              
372 8 50       22 $_on_notify ? $_on_notify->( $payload )
373             : warn "Received NOTIFY packet with no handler\n";
374             }
375              
376             case( SLURM_PKTCTRL_RESPONSE ),
377             case( SLURM_PKTCTRL_ERR ) {
378 5         11 my $slot = $_pending_slots[$seqno];
379 5 50       9 unless( $slot ) {
380 0         0 warn "Received reply to unsent request seqno=$seqno\n";
381 0         0 next;
382             }
383              
384 5 100       9 if( $pktctrl == SLURM_PKTCTRL_RESPONSE ) {
385 4         5 printf STDERR "SLuRM rx-RESPONSE(%d): %v02X\n", $seqno, $payload
386             if DEBUG;
387              
388 4         12 $slot->{response_f}->done( $payload );
389             }
390             else {
391 1         1 printf STDERR "SLuRM rx-ERR(%d): %v02X\n", $seqno, $payload
392             if DEBUG;
393              
394 1 50       10 my $message = sprintf "Received ERR packet <%v02X%s>",
395             substr( $payload, 0, 3 ),
396             length $payload > 3 ? "..." : "";
397 1         7 $slot->{response_f}->fail( $message, slurm => $payload );
398             }
399 5         647 $slot->{retransmit_f}->cancel;
400              
401             $METRICS and
402 5 50       188 $METRICS->report_distribution( request_retries => $_retransmit_count - $slot->{retransmit_count} );
403              
404 5         9 undef $_pending_slots[$seqno];
405              
406 5         16 printf STDERR "SLuRM tx-ACK(%d)\n", $seqno
407             if DEBUG;
408              
409 5         33 await $self->send_packet_twice( SLURM_PKTCTRL_ACK | $seqno, "" );
410             }
411             default {
412 0         0 die sprintf "TODO: Received unrecognised packet type=%02X\n", $pktctrl;
413             }
414             }
415 15 100 66     39 }
    50          
416 12     12   19 }
417              
418             # undocumented but useful for unit tests
419             method _start
420 15     15   264 {
421 15   66 0   78 $_run_f //= $self->_run->on_fail( sub { die "Device::Serial::SLuRM runloop failed: $_[0]" } );
  0         0  
422              
423 15         13210 return $_run_f;
424             }
425              
426             =head2 run
427              
428             $run_f = $slurm->run( %args );
429              
430             Starts the receiver run-loop, which can be used to wait for incoming NOTIFY
431             packets. This method returns a future, but the returned future will not
432             complete in normal circumstances. It will remain pending while the run-loop is
433             running. If an unrecoverable error happens (such as an IO error on the
434             underlying serial port device) then this future will fail.
435              
436             Takes the following named arguments:
437              
438             =over 4
439              
440             =item on_notify => CODE
441              
442             $on_notify->( $payload )
443              
444             Optional. Invoked on receipt of a NOTIFY packet.
445              
446             =back
447              
448             Will automatically L first if required.
449              
450             =cut
451              
452 5         8 async method run ( %args )
  5         9  
  5         8  
453 5         15 {
454 5         7 $_on_notify = $args{on_notify}; # TODO: save old, restore on exit?
455              
456 5 100       14 $_did_reset or
457             await $self->reset;
458              
459             await $self->_start
460 5     5   846 ->on_cancel( sub { undef $_on_notify } );
  5         70  
461 5     5 1 14895 }
462              
463             =head2 stop
464              
465             $slurm->stop;
466              
467             Stops the receiver run-loop, if running, causing its future to be cancelled.
468              
469             It is not an error to call this method if the run loop is not running.
470              
471             =cut
472              
473             method stop
474 12     12 1 33467 {
475 12 50       35 return unless $_run_f;
476              
477 12 50       19 eval { $_run_f->cancel } or warn "Failed to ->cancel the runloop future - $@";
  12         58  
478 12         2593 undef $_run_f;
479             }
480              
481             =head2 send_packet
482              
483             await $slurm->send_packet( $pktctrl, $payload );
484              
485             Sends a packet to the serial port.
486              
487             =cut
488              
489 31         46 async method send_packet ( $pktctrl, $payload )
  31         37  
  31         38  
  31         36  
490 31         65 {
491 31         32 printf STDERR "SLuRM TX-> {%02X/%v02X}\n", $pktctrl, $payload
492             if DEBUG > 1;
493              
494 31         86 my $bytes = pack( "C C", $pktctrl, length $payload );
495 31         80 $bytes .= pack( "C", crc8( $bytes ) );
496              
497 31         839 $bytes .= $payload;
498 31         63 $bytes .= pack( "C", crc8( $bytes ) );
499              
500             $METRICS and
501 31 50 0     705 $METRICS->inc_counter( packets_sent => { type => $PKTTYPE_NAME{ $pktctrl & 0xF0 } // "UNKNOWN" } );
502              
503 31         129 return await Future::IO->syswrite_exactly( $_fh, "\x55" . $bytes );
504 31     31 1 2896 }
505              
506 10         14 async method send_packet_twice ( $pktctrl, $payload )
  10         14  
  10         27  
  10         11  
507 10         28 {
508 10         28 await $self->send_packet( $pktctrl, $payload );
509             # TODO: Send again after a short delay
510 10         31809 await $self->send_packet( $pktctrl, $payload );
511 10     10 0 16 }
512              
513             =head2 reset
514              
515             $slurm->reset;
516              
517             Resets the transmitter sequence number and sends a META-RESET packet.
518              
519             It is not normally required to explicitly call this, as the first call to
520             L, L or L will do it if required.
521              
522             =cut
523              
524             async method reset
525 3         8 {
526 3         6 $_seqno_tx = 0;
527              
528 3         15 await $self->send_packet_twice( SLURM_PKTCTRL_META_RESET, pack "C", $_seqno_tx );
529 3         4267 $_did_reset = 1;
530              
531 3         15 $self->_start;
532              
533             # TODO: These might collide, do we need a Queue?
534 3         11 await $_next_resetack_f = $_run_f->new;
535 3         178 undef $_next_resetack_f;
536 3     3 1 1992 }
537              
538             =head2 send_notify
539              
540             await $slurm->send_notify( $payload )
541              
542             Sends a NOTIFY packet.
543              
544             Will automatically L first if required.
545              
546             =cut
547              
548 2         3 async method send_notify ( $payload )
  2         4  
  2         3  
549 2         8 {
550 2 50       6 $_did_reset or
551             await $self->reset;
552              
553 2         5 ( $_seqno_tx += 1 ) &= 0x0F;
554 2         19 my $pktctrl = SLURM_PKTCTRL_NOTIFY | $_seqno_tx;
555              
556 2         7 await $self->send_packet_twice( $pktctrl, $payload );
557 2     2 1 7297 }
558              
559             =head2 request
560              
561             $data_in = await $slurm->request( $data_out );
562              
563             Sends a REQUEST packet, and waits for a response to it.
564              
565             If the peer responds with an ERR packet, the returned future will fail with
566             an error message, the category of C, and the payload body of the ERR
567             packet in the message details:
568              
569             $f->fail( $message, slurm => $payload );
570              
571             If the peer does not respond at all and all retransmit attempts end in a
572             timeout, the returned future will fail the same way but with C as the
573             message details:
574              
575             $f->fail( $message, slurm => undef );
576              
577             Will automatically L first if required.
578              
579             =cut
580              
581 6         10 async method request ( $payload )
  6         8  
  6         7  
582 6         16 {
583 6 100       15 $_did_reset or
584             await $self->reset;
585              
586 6         68 ( $_seqno_tx += 1 ) &= 0x0F;
587 6         9 my $seqno = $_seqno_tx;
588              
589 6         6 printf STDERR "SLuRM tx-REQUEST(%d): %v02X\n", $seqno, $payload
590             if DEBUG;
591              
592 6 50       13 $_pending_slots[$seqno] and croak "TODO: Request seqno collision - pick a new one?";
593              
594 6         8 my $pktctrl = SLURM_PKTCTRL_REQUEST | $seqno;
595              
596 6         14 await $self->send_packet( $pktctrl, $payload );
597              
598 6         6897 $self->_start;
599              
600 6         17 $_pending_slots[$seqno] = {
601             payload => $payload,
602             response_f => my $f = $_run_f->new,
603             retransmit_count => $_retransmit_count,
604             };
605              
606 6         49 $self->_set_retransmit( $seqno );
607              
608 6         5591 return await $f;
609 6     6 1 5456 }
610              
611 9         12 method _set_retransmit ( $seqno )
  9         12  
  9         10  
612 9     9   21 {
613 9 50       22 my $slot = $_pending_slots[$seqno] or die "ARG expected $seqno request";
614              
615             $slot->{retransmit_f} = Future::IO->sleep( $_retransmit_delay )
616             ->on_done( sub {
617 4 100   4   2160 if( $slot->{retransmit_count}-- ) {
618 3         4 printf STDERR "SLuRM retransmit REQUEST(%d)\n", $seqno
619             if DEBUG;
620              
621 3         5 my $pktctrl = SLURM_PKTCTRL_REQUEST | $seqno;
622             $slot->{retransmit_f} = $self->send_packet( $pktctrl, $slot->{payload} )
623             ->on_fail( sub {
624 0         0 warn "Retransmit failed: @_";
625 0         0 $slot->{response_f}->fail( @_ );
626             } )
627             ->on_done( sub {
628 3         606 $self->_set_retransmit( $seqno );
629 3         8 } );
630              
631 3 50       3229 $METRICS and
632             $METRICS->inc_counter( retransmits => );
633             }
634             else {
635 1         2 printf STDERR "SLuRM timeout REQUEST(%d)\n", $seqno
636             if DEBUG;
637              
638 1         5 my $message = sprintf "Request timed out after %d attempts\n", 1 + $_retransmit_count;
639 1         5 $slot->{response_f}->fail( $message, slurm => undef );
640              
641 1 50       144 $METRICS and
642             $METRICS->inc_counter( timeouts => );
643              
644 1         3 undef $_pending_slots[$seqno];
645             }
646 9         25 });
647             }
648              
649             =head1 AUTHOR
650              
651             Paul Evans
652              
653             =cut
654              
655             0x55AA;