private void _txChar_ValueChanged(GattCharacteristic sender, GattValueChangedEventArgs args) { // enqueue new data var data = new byte[args.CharacteristicValue.Length]; DataReader.FromBuffer(args.CharacteristicValue).ReadBytes(data); _rxData.AddRange(data); // try parsing int consumedBytes = 0; DownlinkMessage msg = null; using (var stream = new MemoryStream(_rxData.ToArray())) { msg = DownlinkMessage.Parser.ParseDelimitedFrom(stream); consumedBytes = (int)stream.Position; } // publish if (msg != null) { _rxData.RemoveRange(0, consumedBytes); DownlinkMessageReceived?.Invoke(this, msg); } }
private static DownlinkMessage BuildDownstreamMessage(LoRaDevice loRaDevice, StationEui stationEUI, ILogger logger, ulong xTime, ReceiveWindow?rx1, ReceiveWindow rx2, RxDelay lnsRxDelay, LoRaPayloadData loRaMessage, LoRaDeviceClassType deviceClassType, uint?antennaPreference = null) { var messageBytes = loRaMessage.Serialize(loRaDevice.AppSKey.Value, loRaDevice.NwkSKey.Value); var downlinkMessage = new DownlinkMessage( messageBytes, xTime, rx1, rx2, loRaDevice.DevEUI, lnsRxDelay, deviceClassType, stationEUI, antennaPreference ); if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug($"{loRaMessage.MessageType} {JsonConvert.SerializeObject(downlinkMessage)}"); } return(downlinkMessage); }
public override void NotifySucceeded(LoRaDevice loRaDevice, DownlinkMessage downlink) { base.NotifySucceeded(loRaDevice, downlink); ResponseDownlink = downlink; ProcessingSucceeded = true; this.complete.Release(); }
public async Task When_Sending_Message_Should_Send_Downlink_To_DownstreamMessageSender(string deviceGatewayID) { var simDevice = new SimulatedDevice(TestDeviceInfo.CreateABPDevice(1, deviceClassType: 'c', gatewayID: deviceGatewayID)); var devEUI = simDevice.DevEUI; this.deviceApi.Setup(x => x.GetPrimaryKeyByEuiAsync(devEUI)) .ReturnsAsync("123"); var twin = simDevice.CreateABPTwin(reportedProperties: new Dictionary <string, object> { { TwinProperty.Region, LoRaRegionType.EU868.ToString() }, { TwinProperty.LastProcessingStationEui, new StationEui(ulong.MaxValue).ToString() } }); this.deviceClient.Setup(x => x.GetTwinAsync(CancellationToken.None)) .ReturnsAsync(twin); if (string.IsNullOrEmpty(deviceGatewayID)) { // will update the fcnt down this.deviceApi.Setup(x => x.NextFCntDownAsync(devEUI, simDevice.FrmCntDown, 0, this.serverConfiguration.GatewayID)) .ReturnsAsync((ushort)(simDevice.FrmCntDown + 1)); } var c2dToDeviceMessage = new ReceivedLoRaCloudToDeviceMessage() { Payload = "hello", DevEUI = devEUI, Fport = TestPort, MessageId = Guid.NewGuid().ToString(), }; DownlinkMessage receivedDownlinkMessage = null; this.downstreamMessageSender.Setup(x => x.SendDownstreamAsync(It.IsNotNull <DownlinkMessage>())) .Returns(Task.CompletedTask) .Callback <DownlinkMessage>(d => receivedDownlinkMessage = d); var target = new DefaultClassCDevicesMessageSender( this.serverConfiguration, this.loRaDeviceRegistry, this.downstreamMessageSender.Object, this.frameCounterStrategyProvider, NullLogger <DefaultClassCDevicesMessageSender> .Instance, TestMeter.Instance); Assert.True(await target.SendAsync(c2dToDeviceMessage)); this.downstreamMessageSender.Verify(x => x.SendDownstreamAsync(It.IsNotNull <DownlinkMessage>()), Times.Once()); EnsureDownlinkIsCorrect(receivedDownlinkMessage, simDevice, c2dToDeviceMessage); this.downstreamMessageSender.VerifyAll(); this.deviceApi.VerifyAll(); this.deviceClient.VerifyAll(); }
public async Task SendDownstreamAsync_Fails_WithNonNullMessage_ButDefaultStationEui() { // arrange var downlinkMessage = new DownlinkMessage(this.loraDataByteArray, 0, new ReceiveWindow(DataRateIndex.DR5, Hertz.Mega(868.5)), new ReceiveWindow(DataRateIndex.DR0, Hertz.Mega(868.5)), this.devEui, RxDelay0, LoRaDeviceClassType.C); // act and assert await Assert.ThrowsAsync <ArgumentException>(() => this.downlinkSender.SendDownstreamAsync(downlinkMessage)); }
private static void EnsureDownlinkIsCorrect(DownlinkMessage downlink, SimulatedDevice simDevice, ReceivedLoRaCloudToDeviceMessage sentMessage) { Assert.NotNull(downlink); Assert.False(downlink.Data.IsEmpty); var downstreamPayloadBytes = downlink.Data; var downstreamPayload = new LoRaPayloadData(downstreamPayloadBytes); Assert.Equal(sentMessage.Fport, downstreamPayload.Fport); Assert.Equal(downstreamPayload.DevAddr, simDevice.DevAddr); var decryptedPayload = downstreamPayload.GetDecryptedPayload(simDevice.AppSKey.Value); Assert.Equal(sentMessage.Payload, Encoding.UTF8.GetString(decryptedPayload)); }
public async Task SendDownstreamAsync_Succeeds_WithValidDownlinkMessage_ClassCDevice(bool rfchHasValue) { // arrange var downlinkMessage = new DownlinkMessage(this.loraDataByteArray, 0, new ReceiveWindow(DataRateIndex.DR5, Hertz.Mega(868.5)), new ReceiveWindow(DataRateIndex.DR0, Hertz.Mega(869.5)), this.devEui, RxDelay0, LoRaDeviceClassType.C, this.stationEui, rfchHasValue ? 1 : null); var actualMessage = string.Empty; this.webSocketWriter.Setup(s => s.SendAsync(It.IsAny <string>(), It.IsAny <CancellationToken>())) .Callback <string, CancellationToken>((message, _) => { actualMessage = message; }); // act await downlinkSender.SendDownstreamAsync(downlinkMessage); // assert Assert.NotEmpty(actualMessage); Assert.Contains(@"""msgtype"":""dnmsg"",", actualMessage, StringComparison.InvariantCulture); Assert.Contains(@"""DevEui"":""FFFFFFFFFFFFFFFF"",", actualMessage, StringComparison.InvariantCulture); Assert.Contains(@"""dC"":2,", actualMessage, StringComparison.InvariantCulture); Assert.Contains(@"""pdu"":""5245465551513D3D"",", actualMessage, StringComparison.InvariantCulture); // Will select DR0 as it is the second DR. Assert.Contains(@"""RX2DR"":0,", actualMessage, StringComparison.InvariantCulture); Assert.Contains(@"""RX2Freq"":869500000,", actualMessage, StringComparison.InvariantCulture); Assert.Contains(@"""priority"":0", actualMessage, StringComparison.InvariantCulture); Assert.DoesNotContain("RxDelay", actualMessage, StringComparison.InvariantCulture); Assert.DoesNotContain("RX1DR", actualMessage, StringComparison.InvariantCulture); Assert.DoesNotContain("RX1Freq", actualMessage, StringComparison.InvariantCulture); Assert.DoesNotContain(@"""xtime"":123456", actualMessage, StringComparison.InvariantCulture); if (rfchHasValue) { Assert.Contains(@"""rctx"":1,", actualMessage, StringComparison.InvariantCulture); } else { Assert.DoesNotContain("rctx", actualMessage, StringComparison.InvariantCulture); } }
private string Message(DownlinkMessage message) { using var ms = new MemoryStream(); using var writer = new Utf8JsonWriter(ms); writer.WriteStartObject(); writer.WriteString("msgtype", LnsMessageType.DownlinkMessage.ToBasicStationString()); writer.WriteString("DevEui", message.DevEui.ToString()); writer.WriteNumber("dC", message.DeviceClassType switch { LoRaDeviceClassType.A => 0, LoRaDeviceClassType.B => 1, LoRaDeviceClassType.C => 2, _ => throw new SwitchExpressionException(), });
public async Task SendDownstreamAsync(DownlinkMessage message) { if (message is null) { throw new ArgumentNullException(nameof(message)); } if (message.StationEui == default) { throw new ArgumentException($"A proper StationEui needs to be set. Received '{message.StationEui}'."); } if (this.socketWriterRegistry.TryGetHandle(message.StationEui, out var webSocketWriterHandle)) { var payload = Message(message); await webSocketWriterHandle.SendAsync(payload, CancellationToken.None); } else { this.logger.LogWarning("Could not retrieve an active connection for Station with EUI '{StationEui}'. The payload '{Payload}' will be dropped.", message.StationEui, message.Data.ToHex()); } }
internal void OnUartTx(byte[] data, ushort length) { // enqueue new data _rxData.AddRange(data); // try parsing int consumedBytes = 0; DownlinkMessage msg = null; using (var stream = new MemoryStream(_rxData.ToArray())) { msg = DownlinkMessage.Parser.ParseDelimitedFrom(stream); consumedBytes = (int)stream.Position; } // publish if (msg != null) { _rxData.RemoveRange(0, consumedBytes); DownlinkMessageReceived?.Invoke(this, msg); } }
public override void NotifySucceeded(LoRaDevice loRaDevice, DownlinkMessage downlink) { this.wrappedRequest.NotifySucceeded(loRaDevice, downlink); TrackProcessingTime(); }
internal DownlinkMessageBuilderResponse(DownlinkMessage downlinkMessage, bool isMessageTooLong, ReceiveWindowNumber?receiveWindow) { DownlinkMessage = downlinkMessage; IsMessageTooLong = isMessageTooLong; ReceiveWindow = receiveWindow; }
public async Task When_Has_Custom_RX2DR_Should_Send_Correctly() { var devAddr = new DevAddr(0x023637F8); var appSKey = TestKeys.CreateAppSessionKey(0xABC0200000000000, 0x09); var nwkSKey = TestKeys.CreateNetworkSessionKey(0xABC0200000000000, 0x09); var simDevice = new SimulatedDevice(TestDeviceInfo.CreateOTAADevice(1, deviceClassType: 'c', gatewayID: ServerGatewayID)); var devEUI = simDevice.DevEUI; simDevice.SetupJoin(appSKey, nwkSKey, devAddr); this.deviceApi.Setup(x => x.GetPrimaryKeyByEuiAsync(devEUI)) .ReturnsAsync("123"); var twin = simDevice.CreateOTAATwin( desiredProperties: new Dictionary <string, object> { { TwinProperty.RX2DataRate, "10" } }, reportedProperties: new Dictionary <string, object> { { TwinProperty.RX2DataRate, 10 }, { TwinProperty.Region, LoRaRegionType.US915.ToString() }, // OTAA device, already joined { TwinProperty.DevAddr, devAddr.ToString() }, { TwinProperty.AppSKey, appSKey.ToString() }, { TwinProperty.NwkSKey, nwkSKey.ToString() }, { TwinProperty.LastProcessingStationEui, new StationEui(ulong.MaxValue).ToString() } }); this.deviceClient.Setup(x => x.GetTwinAsync(CancellationToken.None)) .ReturnsAsync(twin); var c2dToDeviceMessage = new ReceivedLoRaCloudToDeviceMessage() { Payload = "hello", DevEUI = devEUI, Fport = TestPort, MessageId = Guid.NewGuid().ToString(), }; DownlinkMessage receivedDownlinkMessage = null; this.downstreamMessageSender.Setup(x => x.SendDownstreamAsync(It.IsNotNull <DownlinkMessage>())) .Returns(Task.CompletedTask) .Callback <DownlinkMessage>(d => receivedDownlinkMessage = d); var target = new DefaultClassCDevicesMessageSender( this.serverConfiguration, this.loRaDeviceRegistry, this.downstreamMessageSender.Object, this.frameCounterStrategyProvider, NullLogger <DefaultClassCDevicesMessageSender> .Instance, TestMeter.Instance); Assert.True(await target.SendAsync(c2dToDeviceMessage)); this.downstreamMessageSender.Verify(x => x.SendDownstreamAsync(It.IsNotNull <DownlinkMessage>()), Times.Once()); EnsureDownlinkIsCorrect(receivedDownlinkMessage, simDevice, c2dToDeviceMessage); Assert.Equal(DataRateIndex.DR10, receivedDownlinkMessage.Rx2.DataRate); Assert.Equal(Hertz.Mega(923.3), receivedDownlinkMessage.Rx2.Frequency); this.downstreamMessageSender.VerifyAll(); this.deviceApi.VerifyAll(); this.deviceClient.VerifyAll(); }
public LoRaDeviceRequestProcessResult(LoRaDevice loRaDevice, LoRaRequest request, DownlinkMessage downlinkMessage = null) { LoRaDevice = loRaDevice; Request = request; DownlinkMessage = downlinkMessage; }
internal async Task ProcessJoinRequestAsync(LoRaRequest request) { LoRaDevice loRaDevice = null; var loraRegion = request.Region; try { var timeWatcher = request.GetTimeWatcher(); var processingTimeout = timeWatcher.GetRemainingTimeToJoinAcceptSecondWindow() - TimeSpan.FromMilliseconds(100); using var joinAcceptCancellationToken = new CancellationTokenSource(processingTimeout > TimeSpan.Zero ? processingTimeout : TimeSpan.Zero); var joinReq = (LoRaPayloadJoinRequest)request.Payload; var devEui = joinReq.DevEUI; using var scope = this.logger.BeginDeviceScope(devEui); this.logger.LogInformation("join request received"); if (this.concentratorDeduplication.CheckDuplicateJoin(request) is ConcentratorDeduplicationResult.Duplicate) { request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.DeduplicationDrop); // we do not log here as the concentratorDeduplication service already does more detailed logging return; } loRaDevice = await this.deviceRegistry.GetDeviceForJoinRequestAsync(devEui, joinReq.DevNonce); if (loRaDevice == null) { request.NotifyFailed(devEui.ToString(), LoRaDeviceRequestFailedReason.UnknownDevice); // we do not log here as we assume that the deviceRegistry does a more informed logging if returning null return; } if (loRaDevice.AppKey is null) { this.logger.LogError("join refused: missing AppKey for OTAA device"); request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.InvalidJoinRequest); return; } var appKey = loRaDevice.AppKey.Value; this.joinRequestCounter?.Add(1); if (loRaDevice.AppEui != joinReq.AppEui) { this.logger.LogError("join refused: AppEUI for OTAA does not match device"); request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.InvalidJoinRequest); return; } if (!joinReq.CheckMic(appKey)) { this.logger.LogError("join refused: invalid MIC"); request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.JoinMicCheckFailed); return; } // Make sure that is a new request and not a replay if (loRaDevice.DevNonce is { } devNonce&& devNonce == joinReq.DevNonce) { if (string.IsNullOrEmpty(loRaDevice.GatewayID)) { this.logger.LogInformation("join refused: join already processed by another gateway"); } else { this.logger.LogError("join refused: DevNonce already used by this device"); } request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.JoinDevNonceAlreadyUsed); return; } // Check that the device is joining through the linked gateway and not another if (!loRaDevice.IsOurDevice) { this.logger.LogInformation("join refused: trying to join not through its linked gateway, ignoring join request"); request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.HandledByAnotherGateway); return; } var netId = this.configuration.NetId; var appNonce = new AppNonce(RandomNumberGenerator.GetInt32(toExclusive: AppNonce.MaxValue + 1)); var appSKey = OTAAKeysGenerator.CalculateAppSessionKey(appNonce, netId, joinReq.DevNonce, appKey); var nwkSKey = OTAAKeysGenerator.CalculateNetworkSessionKey(appNonce, netId, joinReq.DevNonce, appKey); var address = RandomNumberGenerator.GetInt32(toExclusive: DevAddr.MaxNetworkAddress + 1); // The 7 LBS of the NetID become the NwkID of a DevAddr: var devAddr = new DevAddr(unchecked ((byte)netId.NetworkId), address); var oldDevAddr = loRaDevice.DevAddr; if (!timeWatcher.InTimeForJoinAccept()) { this.receiveWindowMisses?.Add(1); // in this case it's too late, we need to break and avoid saving twins this.logger.LogInformation("join refused: processing of the join request took too long, sending no message"); request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.ReceiveWindowMissed); return; } var updatedProperties = new LoRaDeviceJoinUpdateProperties { DevAddr = devAddr, NwkSKey = nwkSKey, AppSKey = appSKey, AppNonce = appNonce, DevNonce = joinReq.DevNonce, NetId = netId, Region = request.Region.LoRaRegion, PreferredGatewayID = this.configuration.GatewayID, }; if (loRaDevice.ClassType == LoRaDeviceClassType.C) { updatedProperties.SavePreferredGateway = true; updatedProperties.SaveRegion = true; updatedProperties.StationEui = request.StationEui; } DeviceJoinInfo deviceJoinInfo = null; if (request.Region.LoRaRegion == LoRaRegionType.CN470RP2) { if (request.Region.TryGetJoinChannelIndex(request.RadioMetadata.Frequency, out var channelIndex)) { updatedProperties.CN470JoinChannel = channelIndex; deviceJoinInfo = new DeviceJoinInfo(channelIndex); } else { this.logger.LogError("failed to retrieve the join channel index for device"); } } var deviceUpdateSucceeded = await loRaDevice.UpdateAfterJoinAsync(updatedProperties, joinAcceptCancellationToken.Token); if (!deviceUpdateSucceeded) { this.logger.LogError("join refused: join request could not save twin"); request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.IoTHubProblem); return; } var windowToUse = timeWatcher.ResolveJoinAcceptWindowToUse(); if (windowToUse is null) { this.receiveWindowMisses?.Add(1); this.logger.LogInformation("join refused: processing of the join request took too long, sending no message"); request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.ReceiveWindowMissed); return; } this.deviceRegistry.UpdateDeviceAfterJoin(loRaDevice, oldDevAddr); // Build join accept downlink message // Build the DlSettings fields that is a superposition of RX2DR and RX1DROffset field var dlSettings = new byte[1]; if (loRaDevice.DesiredRX2DataRate.HasValue) { if (request.Region.DRtoConfiguration.ContainsKey(loRaDevice.DesiredRX2DataRate.Value)) { dlSettings[0] = (byte)((byte)loRaDevice.DesiredRX2DataRate & 0b00001111); } else { this.logger.LogError("twin RX2 DR value is not within acceptable values"); } } if (request.Region.IsValidRX1DROffset(loRaDevice.DesiredRX1DROffset)) { var rx1droffset = (byte)(loRaDevice.DesiredRX1DROffset << 4); dlSettings[0] = (byte)(dlSettings[0] + rx1droffset); } else { this.logger.LogError("twin RX1 offset DR value is not within acceptable values"); } // The following DesiredRxDelay is different than the RxDelay to be passed to Serialize function // This one is a delay between TX and RX for any message to be processed by joining device // The field accepted by Serialize method is an indication of the delay (compared to receive time of join request) // of when the message Join Accept message should be sent var loraSpecDesiredRxDelay = RxDelay.RxDelay0; if (Enum.IsDefined(loRaDevice.DesiredRXDelay)) { loraSpecDesiredRxDelay = loRaDevice.DesiredRXDelay; } else { this.logger.LogError("twin RX delay value is not within acceptable values"); } var loRaPayloadJoinAccept = new LoRaPayloadJoinAccept( netId, // NETID 0 / 1 is default test devAddr, // todo add device address management appNonce, dlSettings, loraSpecDesiredRxDelay, null); if (!loraRegion.TryGetDownstreamChannelFrequency(request.RadioMetadata.Frequency, upstreamDataRate: request.RadioMetadata.DataRate, deviceJoinInfo: deviceJoinInfo, downstreamFrequency: out var freq)) { this.logger.LogError("could not resolve DR and/or frequency for downstream"); request.NotifyFailed(loRaDevice, LoRaDeviceRequestFailedReason.InvalidUpstreamMessage); return; } var joinAcceptBytes = loRaPayloadJoinAccept.Serialize(appKey); var rx1 = windowToUse is not ReceiveWindow2 ? new ReceiveWindow(loraRegion.GetDownstreamDataRate(request.RadioMetadata.DataRate, loRaDevice.ReportedRX1DROffset), freq) : (ReceiveWindow?)null; var rx2 = new ReceiveWindow(loraRegion.GetDownstreamRX2DataRate(this.configuration.Rx2DataRate, null, deviceJoinInfo, this.logger), loraRegion.GetDownstreamRX2Freq(this.configuration.Rx2Frequency, deviceJoinInfo, this.logger)); var downlinkMessage = new DownlinkMessage(joinAcceptBytes, request.RadioMetadata.UpInfo.Xtime, rx1, rx2, loRaDevice.DevEUI, loraRegion.JoinAcceptDelay1, loRaDevice.ClassType, request.StationEui, request.RadioMetadata.UpInfo.AntennaPreference); this.receiveWindowHits?.Add(1, KeyValuePair.Create(MetricRegistry.ReceiveWindowTagName, (object)windowToUse)); _ = request.DownstreamMessageSender.SendDownstreamAsync(downlinkMessage); request.NotifySucceeded(loRaDevice, downlinkMessage); if (this.logger.IsEnabled(LogLevel.Debug)) { var jsonMsg = JsonConvert.SerializeObject(downlinkMessage); this.logger.LogDebug($"{MacMessageType.JoinAccept} {jsonMsg}"); } else { this.logger.LogInformation("join accepted"); } } catch (Exception ex) when (ExceptionFilterUtility.True(() => this.logger.LogError(ex, $"failed to handle join request. {ex.Message}", LogLevel.Error), () => this.unhandledExceptionCount?.Add(1))) { request.NotifyFailed(loRaDevice, ex); throw; } }
private void _bluetera_DownlinkMessageReceived(IBlueteraDevice sender, DownlinkMessage args) { Dispatcher.Invoke(() => { // update data rate UI //DataRate = _dataRateMeter.DataRate; switch (args.PayloadCase) { case DownlinkMessage.PayloadOneofCase.Quaternion: { // log _dataLogger.Info(args.Quaternion.ToString()); // update rate meter _dataRateMeter.Update(args.Quaternion.Timestamp); DataRate = _dataRateMeter.DataRate; // raw Bluetera quaternion var qRaw = new Quaternion(args.Quaternion.X, args.Quaternion.Y, args.Quaternion.Z, args.Quaternion.W); // transform to WPF model coordinates _qt = new Quaternion(-qRaw.X, -qRaw.Y, qRaw.Z, qRaw.W); // capture the first quanternion as q0 if (_q0.IsIdentity) { _q0 = _qt.Inverse(); } // apply IMU-to-Body transform var qBody = _q0 * _qt * _qbm; model.Transform = new RotateTransform3D(new QuaternionRotation3D(qBody)); // update Euler angles var angles = qBody.GetEuelerAngles(); Roll = angles[0]; Pitch = angles[1]; Yaw = angles[2]; } break; case DownlinkMessage.PayloadOneofCase.Acceleration: // log _dataLogger.Info(args.Acceleration.ToString()); //// update rate meter //_dataRateMeter.Update(args.Acceleration.Timestamp); //DataRate = _dataRateMeter.DataRate; // Update chart AccX = args.Acceleration.X; AccelerationValues_X.Add(AccX); if (AccelerationValues_X.Count > 100) { AccelerationValues_X.RemoveAt(0); } AccY = args.Acceleration.Y; AccelerationValues_Y.Add(AccY); if (AccelerationValues_Y.Count > 100) { AccelerationValues_Y.RemoveAt(0); } AccZ = args.Acceleration.Z; AccelerationValues_Z.Add(AccZ); if (AccelerationValues_Z.Count > 100) { AccelerationValues_Z.RemoveAt(0); } break; default: /* Currently ignore all other message types */ break; } }); }
private static void Device_DownlinkMessageReceived(IBlueteraDevice sender, DownlinkMessage args) { Console.WriteLine($"Recevied message: {args.ToString()}"); }
public virtual void NotifySucceeded(LoRaDevice loRaDevice, DownlinkMessage downlink) { }
public Task SendDownstreamAsync(DownlinkMessage message) { DownlinkMessages.Add(message); return(Task.FromResult(0)); }