-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCanOpenNode.cs
More file actions
1965 lines (1823 loc) · 93.2 KB
/
Copy pathCanOpenNode.cs
File metadata and controls
1965 lines (1823 loc) · 93.2 KB
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
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using CanKit.Abstractions.API.Can;
using CanKit.Abstractions.API.Can.Definitions;
using CanKit.Abstractions.API.Common.Definitions;
using CanKit.Pro.Actor;
using CanKit.Pro.CANopen.Emcy;
using CanKit.Pro.CANopen.Nmt;
using CanKit.Pro.CANopen.Pdo;
using CanKit.Pro.CANopen.Sdo;
using CanKit.Pro.RawCan;
using CanKit.Pro.Reliability;
namespace CanKit.Pro.CANopen;
/// <summary>
/// The default <see cref="ICanOpenNode"/> implementation. Composed on the
/// CanKit.Pro L2 pipeline (<see cref="ICanBusService"/> for RX demux and TX confirmation,
/// <see cref="IProtocolActor"/> for single-writer per-node state, <see cref="DeadlineScheduler"/>
/// for SDO/heartbeat/SYNC timers) exactly like the other Pro protocol stacks
/// (arc42 §8.3, ADR-6; FR-CO-012).
/// </summary>
/// <remarks>
/// <para>
/// The node subscribes to the tight set of CANopen 11-bit COB-IDs it can actually receive
/// (NMT master, SYNC, EMCY(any), SDO Rx for its own id, SDO Tx from any peer, heartbeat/bootup
/// from any peer, and every configured RPDO). It never competes on
/// <see cref="ICanBus.ReceiveAsync"/> — RX flows entirely through
/// <see cref="ISubscription.Frames"/>.
/// </para>
/// <para>
/// All state (NMT slave state machine, SDO client/server sessions, heartbeat consumer table,
/// PDO tables, timer handles) lives inside the actor and is only touched from posted callbacks;
/// public methods marshal work in via <see cref="IProtocolActor.PostAsync{T}"/>. This is the
/// same threading model that the J1939-TP / IsoTp / UDS clients rely on.
/// </para>
/// </remarks>
internal sealed partial class CanOpenNode : ICanOpenNode
{
private readonly ICanBusService _service;
private readonly bool _ownsService;
private readonly byte _nodeId;
private readonly CanOpenNodeOptions _options;
private readonly ProtocolActor _actor;
private readonly DeadlineScheduler _deadlines;
private readonly ISubscription _subscription;
private readonly Task _readerTask;
private readonly CancellationTokenSource _readerCts = new();
// Bounded, drop-oldest queue that decouples user event delivery from the actor loop.
// Events (RPDO / EMCY / heartbeat / SYNC / NMT / user-facing signals) are enqueued from
// the actor thread and drained by a single dispatcher task, so a slow subscriber can never
// stall the protocol loop. Bounded by CanOpenNodeOptions.EventQueueCapacity; when full,
// the oldest queued event is dropped (matches the option's documented semantics).
// BackgroundExceptionOccurred stays synchronous — it is a low-frequency diagnostic signal
// that should never be silently dropped by queue backpressure.
private readonly Channel<Action> _eventChannel;
private readonly Task _eventPumpTask;
private readonly ObjectDictionary _od = new();
// -----------------------------------------------------------------------------------------
// State touched only on the actor loop.
// -----------------------------------------------------------------------------------------
private NmtState _state = NmtState.Initializing;
// SDO server: at most one outstanding segmented transfer against our own OD at a time (a
// second Initiate from the same peer supersedes any previous open transfer per CiA 301
// §7.2.4.3.4).
private SdoServerSession? _sdoServer;
// SDO client: at most one in-flight client-side transfer per remote server (keyed by the
// remote node-id we send to). One client concurrently talking to multiple servers is
// supported (each has its own state entry).
private readonly Dictionary<byte, SdoClientSession> _sdoClients = new();
// SDO block-transfer sessions (FR-CO-004). Block transfer has enough state and enough
// dispatch differences (segment stream vs. control frames) that we keep it in a dedicated
// partial file (CanOpenNode.SdoBlock.cs) with its own session types. A client-side or
// server-side block session for a given peer occupies the same "one transfer at a time
// per peer" slot as the classical SDO client / server; the two are mutually exclusive by
// construction (BeginSdoBlockDownload / BeginSdoBlockUpload each check both dictionaries).
private readonly Dictionary<byte, SdoBlockClientSession> _sdoBlockClients = new();
private SdoBlockServerSession? _sdoBlockServer;
// Node-guarding consumers (FR-CO-009): keyed by producer node-id, each with its own
// guardTime deadline (RTR poll) and life-time deadline (timeout).
private readonly Dictionary<byte, NodeGuardingConsumer> _nodeGuardingConsumers = new();
// Heartbeat producer.
private IDisposable? _heartbeatProducerHandle;
private TimeSpan _heartbeatProducerInterval;
// Heartbeat consumers: node-id → (configured timeout, live deadline).
private readonly Dictionary<byte, HeartbeatConsumer> _heartbeatConsumers = new();
// SYNC producer.
private IDisposable? _syncProducerHandle;
private TimeSpan _syncProducerInterval;
// Node-guarding producer toggle bit (FR-CO-009). CiA 301 §7.2.8.3.3 requires the producer
// to start with toggle=0 and flip it on every reply so the consumer can distinguish a
// fresh answer from a stale duplicate.
private bool _nodeGuardingProducerToggle;
private int _disposed;
/// <inheritdoc />
public byte NodeId => _nodeId;
/// <inheritdoc />
public CanOpenNodeOptions Options => _options;
/// <inheritdoc />
public ObjectDictionary ObjectDictionary => _od;
/// <inheritdoc />
public NmtState State
{
get
{
// When the caller is already on the actor loop (e.g. reading State from a
// SyncReceived / NmtCommandReceived / RpdoReceived handler that the actor itself
// is currently running), synchronously waiting on PostAsync would deadlock the loop
// against itself -- the posted item cannot execute until the current callback
// returns, but the current callback is blocked here waiting for it. Run the read
// inline instead: it is the same single-writer thread that any other State write
// would come from, so no coordination is needed. External callers still marshal
// through the mailbox to see a value consistent with in-flight transitions.
if (_actor.IsOnCurrentActor) return _state;
return _actor.PostAsync(() => _state).GetAwaiter().GetResult();
}
}
/// <inheritdoc />
public event EventHandler<HeartbeatReceivedEventArgs>? HeartbeatReceived;
/// <inheritdoc />
public event EventHandler<HeartbeatTimeoutEventArgs>? HeartbeatTimeout;
/// <inheritdoc />
public event EventHandler<EmcyReceivedEventArgs>? EmcyReceived;
/// <inheritdoc />
public event EventHandler<SyncReceivedEventArgs>? SyncReceived;
/// <inheritdoc />
public event EventHandler<RpdoReceivedEventArgs>? RpdoReceived;
/// <inheritdoc />
public event EventHandler<NmtCommandReceivedEventArgs>? NmtCommandReceived;
/// <inheritdoc />
public event EventHandler<NmtResetEventArgs>? ApplicationReset;
/// <inheritdoc />
public event EventHandler<NodeGuardingReceivedEventArgs>? NodeGuardingReceived;
/// <inheritdoc />
public event EventHandler<NodeGuardingTimeoutEventArgs>? NodeGuardingTimeout;
/// <inheritdoc />
public event EventHandler<LifeGuardingEventArgs>? LifeGuardingEvent;
/// <inheritdoc />
public event EventHandler<Exception>? BackgroundExceptionOccurred;
internal CanOpenNode(ICanBusService service, byte nodeId, CanOpenNodeOptions options,
bool ownsService, ITimeSource? timeSource = null, CanOpenDeviceDescription? description = null)
{
_service = service ?? throw new ArgumentNullException(nameof(service));
CanOpenCobId.ValidateNodeId(nodeId);
_nodeId = nodeId;
_options = options ?? throw new ArgumentNullException(nameof(options));
_options.Validate();
_ownsService = ownsService;
// The time source is a test seam (#113): inhibit times, event timers and every deadline
// are measured against the actor's monotonic source, which a test can drive by hand.
_actor = new ProtocolActor(ActorExecutionMode.DedicatedThread, synchronizationContext: null,
timeSource, shutdownTimeout: null);
_actor.BackgroundExceptionOccurred += (_, ex) => RaiseBackgroundException(ex);
_deadlines = new DeadlineScheduler(_actor);
// The communication-profile objects at their CiA 301 defaults, plus the OD hooks that
// validate writes to them and carry accepted values into the runtime
// (CanOpenNode.CommunicationProfile.cs).
PopulateCommunicationProfile();
// A device description shapes the dictionary on top of that, before the node is on the
// bus (CanOpenNode.DeviceDescription.cs).
if (description is not null) ApplyDeviceDescription(description);
// Change-of-state TPDOs (FR-CO-006): application-originated OD writes trigger
// event-driven TPDOs whose mapping contains the written entry.
_od.EntryWritten += OnOdEntryWrittenForCoS;
// Bounded event queue: drop-oldest keeps steady-state memory constant when a subscriber
// falls behind, exactly matching the CanOpenNodeOptions.EventQueueCapacity contract.
_eventChannel = Channel.CreateBounded<Action>(new BoundedChannelOptions(_options.EventQueueCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false,
});
_eventPumpTask = Task.Run(RunEventPumpAsync);
try
{
// Subscribe once to the tight COB-ID range the node cares about:
// 0x000 (NMT), 0x080..0x0FF (SYNC + EMCY range),
// 0x180..0x77F (PDOs + SDO Rx/Tx + heartbeat range).
// We evaluate the actual routing in the actor since the RPDO table changes at
// runtime, but pre-filtering at the subscription reduces per-frame delegate calls
// on the demux side.
// Echoes are asked for on purpose, and not filtered further. `IsEcho` says "this
// HOST transmitted it", not "this NODE transmitted it", and CanOpen.OpenNode
// documents that several nodes with different node-ids may share one service to
// multiplex CANopen identities over one bus. Dropping host echoes would cut a local
// master off from a local slave -- their SDO transfers, PDOs, heartbeats and NMT
// commands are all genuine peer traffic to each other.
//
// It also keeps `ICanOpenNode.SyncReceived`'s promise ("either from a remote producer
// or from this node's own producer if echo is on"): HandleSync is the only path that
// raises it *and* emits the synchronous TPDOs -- ScheduleSyncProducerTick only puts
// the frame on the wire -- so a SYNC producer that never sees its own SYNC stops
// emitting its own synchronous TPDOs.
//
// What this does NOT do is filter out this node's own traffic. That is not left
// undone -- HandleIncoming does it per message class, where the node id inside the
// COB-ID can be read and where the classes that must keep hearing themselves (SYNC,
// NMT, both SDO directions, RPDOs, a consumer configured for the local id) can be
// exempted individually (#95). It cannot be done here, because at this point a frame
// is only an id in a range.
_subscription = _service.Subscribe(f =>
{
var frame = f.Frame;
if (frame.IsExtendedFrame) return false;
uint id = (uint)frame.ID;
// 0x000 NMT master, 0x080..0x77F everything else CANopen.
return id == CanOpenCobId.NmtCommand || (id >= 0x080 && id <= 0x77F);
}, includeEcho: true);
}
catch
{
_actor.Dispose();
throw;
}
_readerTask = Task.Run(RunReaderAsync);
// Enter Pre-Operational immediately and announce a bootup on the wire. Bootup is a
// one-shot 1-byte frame with data[0] == 0 on COB-ID 0x700 + nodeId
// (CiA 301 §7.2.8.3.2). We do it here at construction so tests can observe it.
_actor.Post(() =>
{
ApplyAllCommunicationObjects();
_state = NmtState.PreOperational;
_ = EmitHeartbeat(0x00);
});
}
/// <inheritdoc />
public Task SendNmtCommandAsync(NmtCommand command, byte targetNodeId,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var payload = new byte[] { (byte)command, targetNodeId };
return SendControlFrame(CanOpenCobId.NmtCommand, payload, cancellationToken);
}
/// <inheritdoc />
public void StartHeartbeatProducer(TimeSpan interval)
{
ThrowIfDisposed();
if (interval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(interval));
// 1017h producer heartbeat time (CiA 301 §7.5.2.20); the runtime follows the OD.
_od.WriteUnsigned(Co.ProducerHeartbeat, 0x00, ToMilliseconds16(interval, nameof(interval), allowZero: false));
}
/// <inheritdoc />
public void StopHeartbeatProducer()
{
if (_disposed != 0) return;
_od.WriteUnsigned(Co.ProducerHeartbeat, 0x00, 0);
}
/// <inheritdoc />
public void AddHeartbeatConsumer(byte producerNodeId, TimeSpan timeout)
{
ThrowIfDisposed();
CanOpenCobId.ValidateNodeId(producerNodeId);
if (timeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout));
ushort ms = ToMilliseconds16(timeout, nameof(timeout), allowZero: false);
// 1016h consumer heartbeat time (CiA 301 §7.5.2.19): reuse the sub-index already
// monitoring this producer, else the first unused one, else grow the array. One
// transaction on the dictionary, so two callers cannot both grow into the same sub-index
// and a direct write of 1016h on another thread waits for the find-or-grow to finish.
_od.Transaction(() =>
{
byte count = (byte)_od.ReadUnsigned(Co.ConsumerHeartbeat, 0x00);
int slot = FindHeartbeatConsumerSlot(producerNodeId, count);
if (slot < 0)
{
for (int s = 1; s <= count; s++)
{
if (_od.TryReadUnsigned(Co.ConsumerHeartbeat, (byte)s, out var v) && (ushort)(v & 0xFFFF) == 0)
{
slot = s;
break;
}
}
}
if (slot < 0)
{
if (count >= 0x7F)
throw new InvalidOperationException("1016h holds at most 127 consumer heartbeat times (CiA 301 §7.5.2.19).");
slot = count + 1;
// The sub-index may already exist: an NMT reset restores sub-index 00h to the
// stored count and zeroes the entries the array had grown by since, but keeps
// them, so growing again reuses such an entry rather than re-declaring it.
if (!_od.TryGet(Co.ConsumerHeartbeat, (byte)slot, out _))
_od.Declare(Co.ConsumerHeartbeat, (byte)slot, OdDataType.Unsigned32, OdAccess.ReadWrite, new byte[4], pdoMappable: false);
// The entry first, while the slot is still outside the count — what a hidden slot
// held is being replaced, not brought back — then the count, which is validated
// against the entry it will show (Codex on #133).
_od.WriteUnsigned(Co.ConsumerHeartbeat, (byte)slot, ((uint)producerNodeId << 16) | ms);
_od.WriteUnsigned(Co.ConsumerHeartbeat, 0x00, (uint)slot);
return;
}
_od.WriteUnsigned(Co.ConsumerHeartbeat, (byte)slot, ((uint)producerNodeId << 16) | ms);
});
}
/// <inheritdoc />
public void RemoveHeartbeatConsumer(byte producerNodeId)
{
if (_disposed != 0) return;
_od.Transaction(() =>
{
byte count = (byte)_od.ReadUnsigned(Co.ConsumerHeartbeat, 0x00);
int slot = FindHeartbeatConsumerSlot(producerNodeId, count);
if (slot > 0) _od.WriteUnsigned(Co.ConsumerHeartbeat, (byte)slot, 0);
});
}
private int FindHeartbeatConsumerSlot(byte producerNodeId, byte count)
{
for (int s = 1; s <= count; s++)
{
if (!_od.TryReadUnsigned(Co.ConsumerHeartbeat, (byte)s, out var v)) continue;
if ((ushort)(v & 0xFFFF) != 0 && (byte)((v >> 16) & 0xFF) == producerNodeId) return s;
}
return -1;
}
/// <inheritdoc />
public void StartSyncProducer(TimeSpan interval)
{
ThrowIfDisposed();
if (interval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(interval));
// 1006h communication cycle period, then bit 30 of 1005h (CiA 301 §7.5.2.5 / §7.5.2.6).
_od.WriteUnsigned(Co.CyclePeriod, 0x00, ToMicroseconds32(interval, nameof(interval)));
_od.WriteUnsigned(Co.SyncCobId, 0x00, _od.ReadUnsigned(Co.SyncCobId, 0x00) | CanOpenCobId.SyncGenerateBit);
}
/// <inheritdoc />
public void StopSyncProducer()
{
if (_disposed != 0) return;
_od.WriteUnsigned(Co.SyncCobId, 0x00, _od.ReadUnsigned(Co.SyncCobId, 0x00) & ~CanOpenCobId.SyncGenerateBit);
}
/// <inheritdoc />
public Task SendSyncAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
// The dictionary, not the actor's copy of it: a caller that has just written 1005h sees
// its own write here, while the runtime copy is updated by a posted apply that may not
// have run yet. The value was validated when the dictionary accepted it.
uint cobId = _od.ReadUnsigned(Co.SyncCobId, 0x00) & CanOpenCobId.CanIdMask;
return SendControlFrame(cobId, Array.Empty<byte>(), cancellationToken);
}
/// <inheritdoc />
public Task SendEmcyAsync(ushort errorCode, byte errorRegister,
ReadOnlyMemory<byte> manufacturerSpecific = default,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var msg = new EmcyMessage(_nodeId, errorCode, errorRegister, manufacturerSpecific.Span);
return _actor.PostAsync(() =>
{
if (!_emcyValid)
throw new InvalidOperationException("EMCY is disabled: bit 31 of 1014h (COB-ID EMCY) is set.");
if (_state == NmtState.Stopped)
{
// §7.3.2.2.4: EMCY triggered in Stopped is pending; the most recent one goes out
// after the transition into another NMT state.
_pendingEmcy = msg;
return Task.CompletedTask;
}
return EmitEmcy(msg, cancellationToken);
}).Unwrap();
}
// Every EMCY this node transmits goes out through one chain, so the wire order is the order
// the actor decided. 1001h is "a part of an emergency object" (CiA 301 §7.5.2.2): the
// register a master reads is the one the last EMCY on the bus carried, so it is written
// inside the chain, right before its frame is transmitted — not when the call was posted,
// nor for an EMCY that is disabled, held or cancelled and never reaches the bus (Codex on
// #133). Actor loop only.
private Task _emcySendChain = Task.CompletedTask;
// Every frame of this node on 0x700 + id — boot-up, state change, producer tick, guarding
// reply — goes out through one chain likewise, so a boot-up ordered by a reset is on the bus
// before the tick that became due while the application's reset hook ran, and before the
// reply to a poll that was already in the mailbox (Codex and Bugbot on #133). Actor loop only.
private Task _heartbeatSendChain = Task.CompletedTask;
private Task EmitHeartbeat(byte payload)
{
uint cobId = CanOpenCobId.Heartbeat(_nodeId);
var frame = new[] { payload };
var link = _heartbeatSendChain.ContinueWith(
_ => SendControlFrame(cobId, frame),
CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default).Unwrap();
_heartbeatSendChain = link;
return link;
}
private Task EmitEmcy(EmcyMessage msg, CancellationToken cancellationToken = default)
{
uint cobId = _emcyCobId;
var frame = msg.Encode();
byte errorRegister = msg.ErrorRegister;
var link = _emcySendChain.ContinueWith(
_ =>
{
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled(cancellationToken);
_od.WriteUnsigned(Co.ErrorRegister, 0x00, errorRegister);
return SendControlFrame(cobId, frame, cancellationToken);
},
CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default).Unwrap();
_emcySendChain = link;
return link;
}
/// <inheritdoc />
public Task<byte[]> SdoUploadAsync(byte serverNodeId, ushort index, byte subindex,
CancellationToken cancellationToken)
=> SdoUploadAsync(serverNodeId, index, subindex, SdoTransferMode.Auto, cancellationToken);
/// <summary>
/// Rejects an <see cref="SdoTransferMode"/> value that is not a defined member.
/// </summary>
/// <remarks>
/// Both transfer entry points route "not <see cref="SdoTransferMode.Block"/>" to the classic
/// client, so an undefined value used to select a transport silently. That is the wrong
/// failure for a value that can only arrive from a bug: an assembly still compiled against
/// 1.2.x supplies the literal <c>1</c> or <c>2</c> for the removed <c>Expedited</c> and
/// <c>Segmented</c> members, and a caller following an out-of-date migration note could pass
/// the same. Throwing names the problem where it happens instead of leaving a wrong transport
/// to be diagnosed on the wire.
/// <para>
/// The message is careful not to say the removed members did nothing. They chose no codec,
/// but at or above <see cref="CanOpenNodeOptions.SdoBlockThresholdBytes"/> they suppressed
/// the <see cref="SdoTransferMode.Auto"/>-to-<see cref="SdoTransferMode.Block"/> switch. A
/// caller who was relying on that and merely drops the argument moves onto block transfer,
/// which is the one migration step that can hang against a peer without block support — so
/// the message names the threshold rather than only saying "drop it".
/// </para>
/// </remarks>
private static void ValidateTransferMode(SdoTransferMode mode, string paramName)
{
if (mode is not (SdoTransferMode.Auto or SdoTransferMode.Block))
{
throw new ArgumentOutOfRangeException(paramName, mode,
$"Unknown {nameof(SdoTransferMode)} value. Only {nameof(SdoTransferMode.Auto)} " +
$"and {nameof(SdoTransferMode.Block)} are defined. The removed Expedited (1) and " +
"Segmented (2) members never chose a codec -- that follows from the payload " +
"length -- so below CanOpenNodeOptions.SdoBlockThresholdBytes the argument can " +
"simply be dropped and the same frames go out. At or above the threshold they " +
"did have one effect: they suppressed the Auto-to-Block switch, so a download " +
"that relied on that must raise SdoBlockThresholdBytes to keep the classic " +
"transport rather than drop the argument alone.");
}
}
/// <inheritdoc />
public Task<byte[]> SdoUploadAsync(byte serverNodeId, ushort index, byte subindex,
SdoTransferMode mode = SdoTransferMode.Auto,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
CanOpenCobId.ValidateNodeId(serverNodeId);
ValidateTransferMode(mode, nameof(mode));
var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
RegisterSdoCancellation(tcs, cancellationToken, serverNodeId);
if (mode == SdoTransferMode.Block)
{
// Block upload path — a separate client-side session shape from expedited /
// segmented, but ownership rules (one in-flight transfer per remote server) are
// shared with the existing SDO client and enforced inside BeginSdoBlockUpload.
_actor.Post(() => BeginSdoBlockUpload(serverNodeId, index, subindex, tcs));
}
else
{
// For Auto uploads we do not know the size up front, so the classic client is the
// conservative default; the SDO server chooses expedited-vs-segmented for us and
// the client handles both response encodings transparently. The Auto → Block
// auto-switch is applied on the *download* path where we know the payload length.
_actor.Post(() => BeginSdoUpload(serverNodeId, index, subindex, tcs));
}
return tcs.Task;
}
/// <inheritdoc />
public Task SdoDownloadAsync(byte serverNodeId, ushort index, byte subindex,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken)
=> SdoDownloadAsync(serverNodeId, index, subindex, data, SdoTransferMode.Auto, cancellationToken);
/// <inheritdoc />
public Task SdoDownloadAsync(byte serverNodeId, ushort index, byte subindex,
ReadOnlyMemory<byte> data,
SdoTransferMode mode = SdoTransferMode.Auto,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
CanOpenCobId.ValidateNodeId(serverNodeId);
ValidateTransferMode(mode, nameof(mode));
if (data.Length == 0)
{
// The expedited encoding steals bit-pair "n" from the CS byte to advertise how many
// of the four payload bytes are valid (n = 4 - length, 2 bits). Length 0 collapses
// onto the same wire encoding as length 4 (n = 0), so a length-0 expedited download
// is indistinguishable from a length-4 one on the receiver. Rather than silently
// sending a bogus 4-byte frame that either a) writes four zero bytes on the peer or
// b) trips a length-mismatch abort, we reject empty downloads here with a clear
// exception. Callers wanting to touch an OD entry without changing its value should
// use a segmented transport (e.g. by embedding at least one meaningful byte).
throw new ArgumentException(
"SDO download payload must contain at least one byte; the CiA 301 expedited " +
"encoding cannot represent a zero-length download and empty payloads are " +
"rejected rather than being silently misencoded as four zero bytes.",
nameof(data));
}
var payload = data.ToArray();
var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
RegisterSdoCancellation(tcs, cancellationToken, serverNodeId);
// Auto-select rules (FR-CO-004):
// * Block was explicitly requested → always block.
// * Auto and payload ≥ SdoBlockThresholdBytes → block.
// * Auto and payload < threshold → classic client, which picks expedited for 1..4
// bytes and segmented above that in BuildDownloadInit. That split is dictated by
// CiA 301 §7.2.4.3.3/§7.2.4.3.5 and is deliberately not caller-selectable.
bool useBlock = mode == SdoTransferMode.Block
|| (mode == SdoTransferMode.Auto && payload.Length >= _options.SdoBlockThresholdBytes);
if (useBlock)
{
_actor.Post(() => BeginSdoBlockDownload(serverNodeId, index, subindex, payload, tcs));
}
else
{
_actor.Post(() => BeginSdoDownload(serverNodeId, index, subindex, payload, tcs));
}
return tcs.Task;
}
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
try { _readerCts.Cancel(); } catch { /* nothing else to do */ }
try
{
_actor.Post(() =>
{
_heartbeatProducerHandle?.Dispose();
_heartbeatProducerHandle = null;
_syncProducerHandle?.Dispose();
_syncProducerHandle = null;
foreach (var kv in _heartbeatConsumers) kv.Value.Deadline?.Dispose();
_heartbeatConsumers.Clear();
DisposePdoRuntime();
_lifeGuardingDeadline?.Dispose();
_lifeGuardingDeadline = null;
_sdoServer?.Deadline?.Dispose();
_sdoServer = null;
foreach (var kv in _sdoClients)
{
kv.Value.Deadline?.Dispose();
kv.Value.Tcs.TrySetException(new ObjectDisposedException(nameof(CanOpenNode)));
}
_sdoClients.Clear();
_sdoBlockServer?.Deadline?.Dispose();
_sdoBlockServer = null;
foreach (var kv in _sdoBlockClients)
{
kv.Value.Deadline?.Dispose();
kv.Value.Tcs.TrySetException(new ObjectDisposedException(nameof(CanOpenNode)));
}
_sdoBlockClients.Clear();
foreach (var kv in _nodeGuardingConsumers)
{
kv.Value.PollHandle?.Dispose();
kv.Value.LifeTimeDeadline?.Dispose();
}
_nodeGuardingConsumers.Clear();
});
}
catch (ObjectDisposedException)
{
// actor already gone; nothing more to do
}
try { _readerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* observed via task; not fatal */ }
// Complete the event channel so the pump task exits after draining anything still
// queued. This keeps ordering: any event enqueued before Dispose is guaranteed to be
// delivered before the pump exits (unless the subscriber itself hangs), while further
// TryWrite calls (a stray raise from an actor callback still winding down) simply
// return false — dropped rather than kept alive past Dispose.
_eventChannel.Writer.TryComplete();
try { _eventPumpTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* observed via task; not fatal */ }
_subscription.Dispose();
_actor.Dispose();
_readerCts.Dispose();
if (_ownsService) _service.Dispose();
}
// =========================================================================================
// Subscription reader -- hands off to the actor loop.
// =========================================================================================
private async Task RunReaderAsync()
{
try
{
await foreach (var frameEvent in _subscription.Frames.WithCancellation(_readerCts.Token)
.ConfigureAwait(false))
{
var frame = frameEvent.Frame;
if (frame.IsExtendedFrame) continue;
uint id = (uint)frame.ID;
// Node-guarding (FR-CO-009) piggy-backs on the heartbeat COB-ID and uses a
// *remote* frame from consumer → producer. Preserve the RTR flag through to
// the actor loop so HandleIncoming can distinguish an RTR poll from a genuine
// heartbeat / node-guarding data frame that happens to share the same COB-ID.
bool isRtr = frame.IsRemoteFrame;
// The copy stays, and #103 asked for the reason rather than the reflex: this
// array is captured into a post that runs later on the actor loop, and the
// handlers below take byte[] and hold it -- an SDO segment lands in a transfer
// that spans many frames, an RPDO's bytes are unpacked into the object
// dictionary. Removing it means threading ReadOnlyMemory<byte> through the whole
// dispatch, which is a different change from the hot-path tidy-up #103 describes.
//
// Unlike the J1939-TP reader one layer over, there is no cheap filter to put in
// front of it: HandleIncoming's first act is to classify the COB-ID, and it needs
// the payload for almost every class it can land in.
var data = frame.Data.ToArray();
_actor.Post(() => HandleIncoming(id, data, isRtr));
}
}
catch (OperationCanceledException) { /* Dispose */ }
catch (Exception ex) { RaiseBackgroundException(ex); }
}
// =========================================================================================
// Event pump — dispatches queued RPDO / EMCY / heartbeat / SYNC / NMT events on its own
// task so that a slow subscriber never blocks the actor loop or the RX reader. Runs a
// single reader so events are delivered in enqueue order.
// =========================================================================================
private async Task RunEventPumpAsync()
{
try
{
while (await _eventChannel.Reader.WaitToReadAsync().ConfigureAwait(false))
{
while (_eventChannel.Reader.TryRead(out var raise))
{
try { raise(); }
catch (Exception ex) { RaiseBackgroundException(ex); }
}
}
}
catch (OperationCanceledException) { /* Dispose */ }
catch (Exception ex) { RaiseBackgroundException(ex); }
}
private void EnqueueEvent(Action raise)
{
// DropOldest mode: TryWrite either enqueues or silently discards the oldest queued
// event to make room. It never blocks and never throws. Once the channel is completed
// (Dispose), TryWrite returns false and the raise is dropped, which is what we want:
// no delivery guarantee is documented past disposal.
_eventChannel.Writer.TryWrite(raise);
}
// Self-traffic guards (#95) are per message class rather than one test at the top, because
// only some COB-IDs identify this node as the *source*. Deliberately unguarded:
// * NMT 0x000 and SYNC 0x080 carry no node id, and a node is documented to act on both from
// its own producer -- ICanOpenNode.SyncReceived says so, and #93 silenced a node's own
// synchronous TPDOs by getting this wrong.
// * 0x600 + id names the destination, so such a frame is ours to serve regardless of sender.
// * an RPDO's COB-ID is whatever the application configured, possibly on purpose our own
// TPDO; overriding that from here is the narrowing #93 had to take back out.
private void HandleIncoming(uint cobId, byte[] data, bool isRtr)
{
try
{
if (cobId == CanOpenCobId.NmtCommand)
{
HandleNmtCommand(data);
return;
}
// SYNC on the COB-ID configured in 1005h (0x080 unless a device description or a
// master moved it).
if (cobId == _syncCobId && !isRtr)
{
HandleSync();
return;
}
// A remote frame is either a PDO read request for our valid TPDOs on that COB-ID
// (CiA 301 §7.2.2.5.2) — two valid records may share one, and then each is read
// (Codex on #133) — or a node-guarding poll addressed to us; nothing else answers an RTR.
if (isRtr)
{
bool requested = false;
for (int n = 1; n <= Co.PdoCount; n++)
{
if (_tpdos[n] is { Valid: true } tpdo && tpdo.CobId == cobId)
{
HandleTpdoRtr(tpdo);
requested = true;
}
}
if (requested) return;
if (cobId == CanOpenCobId.Heartbeat(_nodeId)) HandleNodeGuardingRtrForSelf();
return;
}
// A valid RPDO's COB-ID is whatever the application or a master configured, possibly
// on purpose our own TPDO (#93); an explicitly configured consumer outranks the
// range-based guesses below. Two valid RPDOs may share a COB-ID — the dictionary holds
// both records and CiA 301 does not forbid it — and then each is actuated (Codex on #133).
bool consumed = false;
for (int n = 1; n <= Co.PdoCount; n++)
{
if (_rpdos[n] is { Valid: true } rpdo && rpdo.CobId == cobId)
{
HandleRpdo(rpdo, data);
consumed = true;
}
}
if (consumed) return;
// EMCY 0x081..0x0FF.
if (cobId is >= 0x081 and <= 0x0FF)
{
// Our own, on a bus that echoes (#95): the EMCY COB-ID names the *producer*, so
// this is the node's own emergency coming back. Raising it through EmcyReceived
// would report us to ourselves as a peer in fault.
if (cobId == _emcyCobId) return;
HandleEmcy(cobId, data);
return;
}
// Heartbeat / bootup / node-guarding 0x701..0x77F. Both heartbeat producers and
// node-guarding producers share this COB-ID range; the distinction is: an RTR
// targeting *our* node-id asks the node-guarding producer to reply, and a data
// frame is either a heartbeat / bootup broadcast from a peer or a node-guarding
// response we requested. Bit 7 of the data byte carries the node-guarding toggle
// bit — the existing heartbeat consumer already masks it off via <c>data[0] & 0x7F</c>.
if (cobId is >= 0x701 and <= 0x77F)
{
byte producer = (byte)(cobId - CanOpenCobId.HeartbeatBase);
// Our own heartbeat / bootup / node-guarding response, echoed back (#95). Three
// things it must not swallow:
// * an *RTR* at this COB-ID, which is a consumer polling us and was answered
// above rather than here;
// * a node-guarding consumer registered for our own id;
// * a heartbeat consumer registered for our own id.
// Both of those APIs take a node id and accept the local one, and on an echo bus
// that is a working configuration -- the node's own producer feeds its own
// consumer. Dropping the frame starves the deadline and the timeout fires while
// the frames are arriving (#119, Codex). Same rule the RPDO branch below already
// states: an explicitly configured consumer outranks a guess about the sender.
if (producer == _nodeId
&& !_nodeGuardingConsumers.ContainsKey(producer)
&& !_heartbeatConsumers.ContainsKey(producer))
return;
// Consumer role (FR-CO-009): if we have a node-guarding consumer registered
// for this producer, treat the incoming data frame as a node-guarding reply
// (toggle + state). Otherwise fall through to the heartbeat consumer, which is
// wire-compatible (both frames are 1 byte on 0x700 + node-id, and the toggle
// bit is bit 7). Node-guarding consumers take precedence because a node that
// was set up for node-guarding still wants heartbeat handling elsewhere gated.
if (_nodeGuardingConsumers.ContainsKey(producer))
{
HandleNodeGuardingResponse(producer, data);
return;
}
HandleHeartbeat(cobId, data);
return;
}
// SDO client request: 0x600 + our nodeId — targeted at *our* SDO server.
if (cobId == CanOpenCobId.SdoRx(_nodeId))
{
// Block-download server-side session (if any) intercepts incoming frames while
// it is in a "receiving segments" phase, because a raw block segment's byte 0
// overlaps ordinary segmented-download client-segment / control-frame values.
// The block-server handler returns true if it consumed the frame; false means
// fall through to the plain SDO server.
if (HandleSdoServerRequestBlock(data)) return;
HandleSdoServerRequest(data);
return;
}
// SDO server response: 0x580 + serverNodeId — our SDO *client* is the recipient.
if (cobId is >= CanOpenCobId.SdoTxBase + CanOpenCobId.MinNodeId
and <= CanOpenCobId.SdoTxBase + CanOpenCobId.MaxNodeId)
{
byte serverNodeId = (byte)(cobId - CanOpenCobId.SdoTxBase);
// Neither SDO direction is self-guarded (#95), and the reason is the same both
// ways: nothing here reports a peer to the application, so there is nothing for
// an echo to misreport. 0x600 + id names the *destination*, so such a frame is
// ours to serve whoever sent it. 0x580 + id does name us as the sender, but both
// client handlers open with a lookup keyed on that id -- _sdoClients and
// _sdoBlockClients -- and an id with no session is already dropped.
//
// A guard here was written and taken back out: it could only subtract. When no
// session is keyed to our own id it does what the lookup already does, and when
// one is -- a node running an SDO transfer against its own server on an echo bus
// -- it drops the very response that transfer is waiting for.
// Symmetric to the server-side path above: while a client-side block session
// (upload or download) is in a "receiving segments" phase, incoming frames on
// this COB-ID are block segments rather than ordinary SDO responses.
if (HandleSdoClientResponseBlock(serverNodeId, data)) return;
HandleSdoClientResponse(serverNodeId, data);
return;
}
}
catch (Exception ex)
{
RaiseBackgroundException(ex);
}
}
// =========================================================================================
// NMT slave state machine (FR-CO-007)
// =========================================================================================
private void HandleNmtCommand(byte[] data)
{
if (data.Length < 2) return;
var cmd = (NmtCommand)data[0];
byte target = data[1];
// 0 = broadcast (all nodes); otherwise apply only when the target is us.
// Raise NmtCommandReceived only for matching targets — ICanOpenNode documents the
// event for commands that address this node (or broadcast), not every peer NMT on a
// shared bus (Bugbot 3600812708).
bool forUs = target == 0 || target == _nodeId;
if (!forUs) return;
RaiseNmtCommandReceived(cmd, target);
// The transitions themselves live in CanOpenNode.CommunicationProfile.cs, next to the
// reset that restores the communication-profile objects (CiA 301 §7.3.2).
switch (cmd)
{
case NmtCommand.Start:
ApplyNmtTransition(NmtState.Operational);
break;
case NmtCommand.Stop:
ApplyNmtTransition(NmtState.Stopped);
break;
case NmtCommand.EnterPreOperational:
ApplyNmtTransition(NmtState.PreOperational);
break;
case NmtCommand.ResetNode:
PerformNmtReset(communicationOnly: false);
break;
case NmtCommand.ResetCommunication:
PerformNmtReset(communicationOnly: true);
break;
}
}
// =========================================================================================
// SYNC (FR-CO-010)
// =========================================================================================
// HandleSync lives in CanOpenNode.Pdo.cs: the SYNC is the trigger of every synchronous PDO.
private void ScheduleSyncProducerTick()
{
if (_syncProducerInterval <= TimeSpan.Zero) return;
_syncProducerHandle = _actor.Schedule(_syncProducerInterval, () =>
{
try
{
if (_disposed != 0) return;
// CiA 301 Table 37: SYNC is not active in Stopped; the producer keeps its cycle
// and resumes transmitting when the node leaves Stopped.
if (_state is NmtState.Stopped or NmtState.Initializing) return;
_ = SendControlFrame(_syncCobId, Array.Empty<byte>());
}
finally
{
if (_disposed == 0 && _syncProducerInterval > TimeSpan.Zero)
ScheduleSyncProducerTick();
}
});
}
// =========================================================================================
// EMCY (FR-CO-011)
// =========================================================================================
private void HandleEmcy(uint cobId, byte[] data)
{
if (data.Length < EmcyMessage.WireSize) return;
byte producer = (byte)(cobId - CanOpenCobId.EmcyBase);
var msg = EmcyMessage.Decode(producer, data);
RaiseEmcyReceived(msg, DateTime.UtcNow);
}
// =========================================================================================
// Heartbeat (FR-CO-008)
// =========================================================================================
private void HandleHeartbeat(uint cobId, byte[] data)
{
if (data.Length < 1) return;
byte producer = (byte)(cobId - CanOpenCobId.HeartbeatBase);
// Bit 7 is the node-guarding toggle; a heartbeat carries 0 there (§7.2.8.3.2.2, "r:
// reserved (always 0)"), and a guarding reply is routed to HandleNodeGuardingResponse
// before it gets here, so the bit is masked rather than interpreted.
byte stateByte = (byte)(data[0] & 0x7F);
NmtState state = stateByte switch
{
0x00 => NmtState.Initializing, // Bootup frame.
0x04 => NmtState.Stopped,
0x05 => NmtState.Operational,
0x7F => NmtState.PreOperational,
_ => NmtState.Initializing,
};
RaiseHeartbeatReceived(producer, state, DateTime.UtcNow);
if (_heartbeatConsumers.TryGetValue(producer, out var consumer))
{
// Rearm the deadline — best-effort. On failure, allocate a fresh one to preserve
// the semantic "if we do not see another heartbeat within timeout, fire".
var deadline = consumer.Deadline;
if (deadline is null || deadline.IsExpired || deadline.IsCancelled || !deadline.Rearm(consumer.Timeout))
{
deadline?.Dispose();
consumer.Deadline = _deadlines.Arm(consumer.Timeout, () => OnHeartbeatMissed(producer));
}
}
}
private void OnHeartbeatMissed(byte producerNodeId)
{
if (!_heartbeatConsumers.TryGetValue(producerNodeId, out var consumer)) return;
// Rearm so subsequent misses still fire; consumer explicitly re-registered on every
// heartbeat receipt above, but if the heartbeat is completely absent we keep firing.
consumer.Deadline?.Dispose();
consumer.Deadline = _deadlines.Arm(consumer.Timeout, () => OnHeartbeatMissed(producerNodeId));
RaiseHeartbeatTimeout(producerNodeId, consumer.Timeout);
}
private void ScheduleHeartbeatProducerTick()
{
if (_heartbeatProducerInterval <= TimeSpan.Zero) return;
_heartbeatProducerHandle = _actor.Schedule(_heartbeatProducerInterval, () =>
{
try
{
if (_disposed != 0) return;
_ = EmitHeartbeat((byte)_state);
}
finally
{
if (_disposed == 0 && _heartbeatProducerInterval > TimeSpan.Zero)
ScheduleHeartbeatProducerTick();
}
});
}
// =========================================================================================
// SDO server (FR-CO-002 / FR-CO-003 / FR-CO-001 access-check)
// =========================================================================================
private void HandleSdoServerRequest(byte[] data)
{
// CiA 301: SDO is not available in Stopped (or while still Initializing). Drop the
// request rather than serving a transfer that should be offline (Bugbot 3600879338).
if (_state is NmtState.Stopped or NmtState.Initializing)
return;
if (data.Length == 0) return; // nothing to look at — can't even read the CS byte
if (data.Length < 8)
{
// Match the symmetric client-side handling (see HandleSdoClientResponse): pad
// trailing-zero-stripped SDO frames to 8 bytes rather than dropping them, since