pamoja.lorawan

Idiomatic LoRaWAN facade.

A long-range public-band link is wide open, so LoRaWAN wraps every frame in two guarantees: a message integrity code keyed to the network proves the frame is authentic and intact, and the payload is encrypted to the application so only its owner can read it. This builds and verifies exactly that.

  1"""Idiomatic LoRaWAN facade.
  2
  3A long-range public-band link is wide open, so LoRaWAN wraps every frame in two
  4guarantees: a message integrity code keyed to the network proves the frame is
  5authentic and intact, and the payload is encrypted to the application so only its
  6owner can read it. This builds and verifies exactly that.
  7"""
  8
  9from __future__ import annotations
 10
 11import enum
 12
 13from pamoja._native import (
 14    LORAWAN_ADR_ACK_DELAY,
 15    LORAWAN_ADR_ACK_LIMIT,
 16    LORAWAN_JOIN_ACCEPT_DELAY1_US,
 17    LORAWAN_JOIN_ACCEPT_DELAY2_US,
 18    LORAWAN_MAX_FCNT_GAP,
 19    LORAWAN_RECEIVE_DELAY1_US,
 20    LORAWAN_RECEIVE_DELAY2_US,
 21    LORAWAN_RECEIVE_WINDOW_TOLERANCE_US,
 22    LORAWAN_RETRANSMIT_TIMEOUT_MAX_US,
 23    LORAWAN_RETRANSMIT_TIMEOUT_MIN_US,
 24    LORAWAN_SAVED_LEN,
 25    LorawanBackoff,
 26    LorawanBackoffStep,
 27    LorawanCfList,
 28    LorawanChannel,
 29    LorawanDelivery,
 30    LorawanDevice,
 31    LorawanDeviceError,
 32    LorawanDeviceSettings,
 33    LorawanEndDevice,
 34    LorawanHeard,
 35    LorawanNext,
 36    LorawanTransmission,
 37    LorawanWindow,
 38    LorawanGrant,
 39    LorawanHeader,
 40    LorawanJoinAccept,
 41    LorawanJoinRequest,
 42    LorawanMacCommand,
 43    LorawanPackageCommand,
 44    LorawanRxData,
 45    LorawanSession,
 46    LorawanAckWindow,
 47    LorawanAcknowledgment,
 48    LorawanListen,
 49    LorawanRelay,
 50    LorawanRelayError,
 51    LorawanRelayExchange,
 52    LorawanRelayHeard,
 53    LorawanRelayStatus,
 54    LorawanRxrDownlink,
 55    LorawanScan,
 56    LorawanWake,
 57    LorawanWakeUp,
 58    LorawanWorNext,
 59)
 60from pamoja._native import lorawan_mac_parse as _mac_parse
 61from pamoja._native import lorawan_package_encode as _package_encode
 62from pamoja._native import lorawan_package_parse as _package_parse
 63from pamoja._native import lorawan_package_parse_all as _package_parse_all
 64from pamoja._native import lorawan_package_status_item as _package_status_item
 65from pamoja._native import lorawan_parse_header as _parse_header
 66from pamoja._native import lorawan_parse_join_request as _parse_join_request
 67
 68from pamoja.lorawan import clock, firmware, fragment, multicast, relay
 69
 70__all__ = [
 71    "ADR_ACK_DELAY",
 72    "ADR_ACK_LIMIT",
 73    "AckWindow",
 74    "Acknowledgment",
 75    "Backoff",
 76    "BackoffStep",
 77    "CfList",
 78    "Channel",
 79    "Delivery",
 80    "Device",
 81    "DeviceError",
 82    "DeviceSettings",
 83    "Direction",
 84    "EndDevice",
 85    "Grant",
 86    "Header",
 87    "Heard",
 88    "JOIN_ACCEPT_DELAY1_US",
 89    "JOIN_ACCEPT_DELAY2_US",
 90    "JoinAccept",
 91    "JoinRequest",
 92    "Listen",
 93    "MAX_FCNT_GAP",
 94    "MacCommand",
 95    "MessageType",
 96    "Next",
 97    "PackageCommand",
 98    "RECEIVE_DELAY1_US",
 99    "RECEIVE_DELAY2_US",
100    "RECEIVE_WINDOW_TOLERANCE_US",
101    "RETRANSMIT_TIMEOUT_MAX_US",
102    "RETRANSMIT_TIMEOUT_MIN_US",
103    "ReceiveWindow",
104    "Relay",
105    "RelayActivation",
106    "RelayError",
107    "RelayExchange",
108    "RelayHeard",
109    "RelayStatus",
110    "RelaySync",
111    "RxData",
112    "RxrDownlink",
113    "SAVED_LEN",
114    "Scan",
115    "Session",
116    "Transmission",
117    "Version",
118    "Wake",
119    "WakeUp",
120    "Window",
121    "WorNext",
122    "clock",
123    "device",
124    "end_device",
125    "firmware",
126    "fragment",
127    "grant",
128    "mac_parse",
129    "multicast",
130    "package_encode",
131    "package_parse",
132    "package_parse_all",
133    "package_status_item",
134    "parse_header",
135    "parse_join_request",
136    "relay",
137    "session",
138]
139
140#: An activated session: a device address and its two session keys.
141Session = LorawanSession
142#: The root credentials over-the-air activation is built on.
143Device = LorawanDevice
144#: An accepted join: the network settings, and the session it grants.
145JoinAccept = LorawanJoinAccept
146#: A decoded data frame, with its payload decrypted.
147RxData = LorawanRxData
148#: What a frame says about itself before any key is involved.
149Header = LorawanHeader
150#: A join-request a device broadcast, with its integrity already verified.
151JoinRequest = LorawanJoinRequest
152#: What a network grants a device that joined.
153Grant = LorawanGrant
154#: The optional channel list at the end of a join accept.
155CfList = LorawanCfList
156#: A device's count of how long the network has been silent.
157Backoff = LorawanBackoff
158#: What a back-off says to do with one uplink.
159BackoffStep = LorawanBackoffStep
160#: A LoRaWAN Class A end device, without a radio.
161EndDevice = LorawanEndDevice
162#: What a device's radio can do, and how it takes part.
163DeviceSettings = LorawanDeviceSettings
164#: Raised when an end device cannot do what it was asked; ``kind`` names why.
165DeviceError = LorawanDeviceError
166#: A frame to put on the air, and where to listen afterward.
167Transmission = LorawanTransmission
168#: When and where to listen for a downlink.
169Window = LorawanWindow
170#: What a frame heard in a receive window turned out to be.
171Heard = LorawanHeard
172#: A downlink, read and acted on.
173Delivery = LorawanDelivery
174#: What to do once both receive windows closed with nothing for the device.
175Next = LorawanNext
176#: A channel a device may send on.
177Channel = LorawanChannel
178#: A LoRaWAN relay: an end device that also carries the uplinks of the devices around it.
179Relay = LorawanRelay
180#: Raised when a relay cannot do what it was asked; ``kind`` names why.
181RelayError = LorawanRelayError
182#: A scan for wake-on-radio frames, due next.
183Scan = LorawanScan
184#: What a wake-on-radio frame led a relay to do.
185Wake = LorawanWake
186#: The acknowledgment a relay answers a wake-on-radio frame with.
187Acknowledgment = LorawanAcknowledgment
188#: When and where a relay listens for the uplink a wake-on-radio frame announced.
189Listen = LorawanListen
190#: What a frame a relay's own device heard turned out to be.
191RelayHeard = LorawanRelayHeard
192#: A downlink for an end device, to send in its relay window.
193RxrDownlink = LorawanRxrDownlink
194#: The wake-on-radio exchange an uplink under a relay goes out behind.
195RelayExchange = LorawanRelayExchange
196#: The frame that wakes a relay, and where it goes.
197WakeUp = LorawanWakeUp
198#: When and where a relay's acknowledgment would arrive.
199AckWindow = LorawanAckWindow
200#: What a relay's acknowledgment said about itself.
201RelayStatus = LorawanRelayStatus
202#: What an end device does once its wake-on-radio frame went unanswered.
203WorNext = LorawanWorNext
204#: How many bytes a saved device state takes.
205SAVED_LEN = LORAWAN_SAVED_LEN
206
207#: How long after an uplink the first receive window opens, RP002-1.0.5 section 3.3.
208RECEIVE_DELAY1_US = LORAWAN_RECEIVE_DELAY1_US
209#: How long after an uplink the second receive window opens.
210RECEIVE_DELAY2_US = LORAWAN_RECEIVE_DELAY2_US
211#: How long after a join request the first join accept window opens.
212JOIN_ACCEPT_DELAY1_US = LORAWAN_JOIN_ACCEPT_DELAY1_US
213#: How long after a join request the second join accept window opens.
214JOIN_ACCEPT_DELAY2_US = LORAWAN_JOIN_ACCEPT_DELAY2_US
215#: How far a receive window may open either side of its time, LoRaWAN 1.0.3 section 3.3.1.
216RECEIVE_WINDOW_TOLERANCE_US = LORAWAN_RECEIVE_WINDOW_TOLERANCE_US
217#: The largest gap a frame counter may jump across and still be accepted.
218MAX_FCNT_GAP = LORAWAN_MAX_FCNT_GAP
219#: How many unanswered uplinks before a device asks the network to answer.
220ADR_ACK_LIMIT = LORAWAN_ADR_ACK_LIMIT
221#: How many more before a device starts giving back what adaptive data rate took.
222ADR_ACK_DELAY = LORAWAN_ADR_ACK_DELAY
223#: The shortest wait before a confirmed uplink is sent again.
224RETRANSMIT_TIMEOUT_MIN_US = LORAWAN_RETRANSMIT_TIMEOUT_MIN_US
225#: The longest wait before a confirmed uplink is sent again.
226RETRANSMIT_TIMEOUT_MAX_US = LORAWAN_RETRANSMIT_TIMEOUT_MAX_US
227
228
229class Version(str, enum.Enum):
230    """A revision of the LoRaWAN link layer, as :class:`Backoff` takes it.
231
232    >>> backoff = Backoff(Version.V1_0_3)
233    >>> backoff.version
234    '1.0.3'
235    """
236
237    #: LoRaWAN 1.0.3.
238    V1_0_3 = "1.0.3"
239    #: TS001-1.0.4, the LoRaWAN 1.0.4 link layer.
240    V1_0_4 = "1.0.4"
241
242
243class ReceiveWindow(str, enum.Enum):
244    """Which receive window a frame arrived in, as :meth:`EndDevice.heard` takes it.
245
246    TS001-1.0.4 section 4.1 has a device discard a frame whose MACPayload is longer than
247    the data rate it was received at carries, so a device told the window holds each frame
248    to that window's limit.
249    """
250
251    #: The first window, on the uplink's downlink channel.
252    RX1 = "rx1"
253    #: The second, on the fixed frequency and data rate.
254    RX2 = "rx2"
255    #: The relay window, which a device under a relay opens last, TS011-1.0.1 chapter 7.
256    RXR = "rxr"
257
258
259class RelayActivation(str, enum.Enum):
260    """How an end device decides whether to send through a relay, TS011-1.0.1 section 10.2."""
261
262    #: Never, the default.
263    DISABLED = "disabled"
264    #: Always.
265    ENABLED = "enabled"
266    #: Only once a run of uplinks went unanswered, as the network's smart-enable level sets.
267    DYNAMIC = "dynamic"
268    #: However the device itself decides, which :meth:`EndDevice.use_relay` sets.
269    DEVICE_CONTROLLED = "device_controlled"
270
271
272class RelaySync(str, enum.Enum):
273    """What an end device knows of its relay's scans, TS011-1.0.1 section 3.9."""
274
275    #: Nothing: no wake-on-radio frame has gone out yet.
276    INITIALIZED = "initialized"
277    #: One went out unanswered, so the next preamble spans a whole scan period.
278    UNSYNCHRONIZED = "unsynchronized"
279    #: It knows when the relay scans, so a short preamble reaches it.
280    SYNCHRONIZED = "synchronized"
281
282
283class MessageType(str, enum.Enum):
284    """What kind of message a frame is, read from its header."""
285
286    #: A device asking to join a network.
287    JOIN_REQUEST = "JoinRequest"
288    #: A network admitting a device.
289    JOIN_ACCEPT = "JoinAccept"
290    #: Data from a device that does not need acknowledging.
291    UNCONFIRMED_UP = "UnconfirmedUp"
292    #: Data from a device that asks to be acknowledged.
293    CONFIRMED_UP = "ConfirmedUp"
294    #: Data to a device that does not need acknowledging.
295    UNCONFIRMED_DOWN = "UnconfirmedDown"
296    #: Data to a device that asks to be acknowledged.
297    CONFIRMED_DOWN = "ConfirmedDown"
298
299
300class Direction(str, enum.Enum):
301    """The direction a frame traveled, which its MIC and encryption fold in."""
302
303    #: From an end device up to the network.
304    UPLINK = "Uplink"
305    #: From the network down to an end device.
306    DOWNLINK = "Downlink"
307
308
309def session(dev_addr: int, nwk_skey: bytes, app_skey: bytes) -> LorawanSession:
310    """Create a session for a device already activated by personalization.
311
312    :param dev_addr: The device address the network assigned.
313    :param nwk_skey: The 16-byte network session key, which authenticates frames.
314    :param app_skey: The 16-byte application session key, which encrypts payloads.
315    :returns: The session, ready to encode and decode data frames.
316    :raises PamojaError: If either key is not 16 bytes.
317    """
318    return LorawanSession(dev_addr, bytes(nwk_skey), bytes(app_skey))
319
320
321def device(dev_eui: bytes, app_eui: bytes, app_key: bytes) -> LorawanDevice:
322    """Create a device holding the root credentials for over-the-air activation.
323
324    :param dev_eui: The 8-byte device EUI.
325    :param app_eui: The 8-byte application (join) EUI.
326    :param app_key: The 16-byte application key the join exchange is secured with.
327    :returns: The device, ready to build a join request.
328    :raises PamojaError: If any credential is the wrong length.
329    """
330    return LorawanDevice(bytes(dev_eui), bytes(app_eui), bytes(app_key))
331
332
333def parse_header(data: bytes) -> Header:
334    """Read a frame far enough to route it, without any key.
335
336    A receiver holding many sessions uses this to find which one a frame belongs
337    to: the device address travels in the clear, so it can be read before the
338    session that would verify the frame is even known.
339
340    Nothing this reports is authenticated. Treat it as a routing hint until
341    :meth:`Session.decode` has verified the frame.
342
343    :param data: The raw frame as it came off the radio.
344    :returns: What the header says the frame is.
345    :raises PamojaError: If the frame is truncated or carries a message type this
346        build does not read.
347    """
348    return _parse_header(bytes(data))
349
350
351def parse_join_request(data: bytes, app_key: bytes) -> JoinRequest:
352    """Verify a join-request and read the identifiers out of it.
353
354    This is the network side of activation: it proves the request came from a
355    holder of the application key before reporting who sent it.
356
357    :param data: The raw join-request as it came off the radio.
358    :param app_key: The 16-byte application root key the device shares.
359    :returns: The verified request.
360    :raises PamojaError: If the MIC does not verify or the frame is not a
361        join-request.
362    """
363    return _parse_join_request(bytes(data), bytes(app_key))
364
365
366def grant(
367    app_nonce: int,
368    net_id: int,
369    dev_addr: int,
370    dl_settings: int = 0,
371    rx_delay: int = 0,
372    cflist: bytes | None = None,
373) -> Grant:
374    """Describe what this network grants a device that joined.
375
376    :param app_nonce: A nonce this network must not reuse for the device, since
377        the session keys are derived from it; low 24 bits only.
378    :param net_id: The network identifier; low 24 bits only.
379    :param dev_addr: The address to assign the device.
380    :param dl_settings: The downlink settings byte.
381    :param rx_delay: The delay before the first receive window, in seconds.
382    :param cflist: The optional 16-byte channel list.
383    :returns: The grant, which signs its own join-accept and derives the session.
384    :raises PamojaError: If the channel list is not 16 bytes.
385    """
386    return Grant(
387        app_nonce,
388        net_id,
389        dev_addr,
390        dl_settings,
391        rx_delay,
392        None if cflist is None else bytes(cflist),
393    )
394
395
396#: One of the commands a network and a device configure each other with.
397#:
398#: kind names the command and cid is the identifier it travels under. Only the
399#: fields that command carries are set; the rest are None. Writing one out is
400#: command.encode().
401MacCommand = LorawanMacCommand
402
403
404def mac_parse(direction: Direction | str, data: bytes) -> list[MacCommand]:
405    """Read the commands packed into a frame options field, or a port 0 payload.
406
407    The same identifier means a different command in each direction, so the direction
408    decides what is read and there is no default: 0x03 going down is a request to
409    change data rate, and the same byte coming up answers one.
410
411    A command does not carry its own length, so one this build does not know cannot be
412    stepped over. Reading stops there and returns what came before it.
413
414    Args:
415        direction: Which way the frame carrying them travels.
416        data: The options field, or the payload.
417
418    Returns:
419        The commands that were readable, in order.
420    """
421    name = direction.value if isinstance(direction, enum.Enum) else str(direction)
422    return _mac_parse(name, data)
423
424
425def end_device(
426    plan,
427    dev_eui: bytes,
428    join_eui: bytes,
429    app_key: bytes,
430    settings: LorawanDeviceSettings,
431    fcnt_up: int = 0,
432    fcnt_down: int | None = None,
433) -> LorawanEndDevice:
434    """Make an end device that joins over the air.
435
436    It owns no radio and no clock: every call takes the time in microseconds and hands
437    back what to put on the air, so the same device runs over any radio, or in a test
438    with none. :meth:`EndDevice.join` or :meth:`EndDevice.send` returns a transmission
439    and two receive windows; a frame heard in either goes to :meth:`EndDevice.heard`,
440    and :meth:`EndDevice.nothing_heard` says what to do when neither held one.
441
442    :param plan: A published channel plan, from ``pamoja.lora.plan_for`` or
443        ``pamoja.lora.cn470_plan``.
444    :param dev_eui: The 8-byte device EUI.
445    :param join_eui: The 8-byte join EUI.
446    :param app_key: The 16-byte root key.
447    :param settings: What the radio can do.
448    :param fcnt_up: The next uplink frame counter carried over a restart.
449    :param fcnt_down: The last downlink frame counter accepted, if any was.
450    :returns: The device, not yet joined.
451    :raises PamojaError: If the plan was built rather than published, or a credential is
452        the wrong length.
453
454    >>> from pamoja import lora
455    >>> node = end_device(lora.plan_for("EU868"), bytes(8), bytes(8), bytes(16), DeviceSettings(2, 14))
456    >>> request = node.join(1, 0)
457    >>> request.rx1.delay_us
458    5000000
459    """
460    return LorawanEndDevice.over_the_air(
461        plan,
462        LorawanDevice(bytes(dev_eui), bytes(join_eui), bytes(app_key)),
463        settings,
464        fcnt_up,
465        fcnt_down,
466    )
467
468
469#: One command of an application layer package, whichever package it belongs to.
470#:
471#: ``port`` says which package and ``kind`` names the command within it; together they decide
472#: which of the other fields carry anything. The rest are ``None``. The same identifier means a
473#: different command in each direction, so ``uplink`` decides which one this is.
474PackageCommand = LorawanPackageCommand
475
476
477def package_parse(port: int, uplink: bool, data: bytes) -> PackageCommand:
478    """Read one command of an application layer package.
479
480    A data fragment takes the whole message, as TS004-2.0.0 asks, and its bytes come back
481    on ``data``.
482
483    Args:
484        port: Which package: one of :data:`clock.PORT`, :data:`fragment.PORT`,
485            :data:`multicast.PORT` or :data:`firmware.PORT`.
486        uplink: Whether the frame carrying it traveled up.
487        data: The message, from this command's identifier on.
488
489    Returns:
490        The command that was read.
491
492    Raises:
493        PamojaError: If the port names no package, the message ends inside the command,
494            or the identifier is not one the package defines.
495    """
496    return _package_parse(port, uplink, data)
497
498
499def package_parse_all(port: int, uplink: bool, data: bytes) -> list[PackageCommand]:
500    """Read every command in one message.
501
502    A command does not carry its own length, so one this build does not know cannot be
503    stepped over. Reading stops there and returns what came before it.
504
505    Args:
506        port: Which package.
507        uplink: Whether the frame carrying them traveled up.
508        data: The message.
509
510    Returns:
511        The commands that were readable, in order.
512
513    Raises:
514        PamojaError: If the port names no package.
515    """
516    return _package_parse_all(port, uplink, data)
517
518
519def package_status_item(data: bytes) -> PackageCommand:
520    """Read one group record of a multicast status answer.
521
522    The answer says how many groups follow; each is five bytes and carries no identifier
523    of its own, so they are read one at a time rather than by :func:`package_parse_all`.
524
525    Args:
526        data: The message, from the record on.
527
528    Returns:
529        The record, as an ``mc_group_status_item`` command.
530
531    Raises:
532        PamojaError: If the message ends inside the record.
533    """
534    return _package_status_item(data)
535
536
537def package_encode(port: int, kind: str, uplink: bool = False, **fields: object) -> bytes:
538    """Write one command of an application layer package.
539
540    Args:
541        port: Which package.
542        kind: Which command within it.
543        uplink: Which way it travels.
544        **fields: The fields that command carries; see :class:`PackageCommand`.
545
546    Returns:
547        The bytes it goes out as.
548
549    Raises:
550        PamojaError: If the port names no package, the name is not a command that package
551            defines, or a field the command needs is absent.
552    """
553    return _package_encode(PackageCommand(port, kind, uplink, **fields))
ADR_ACK_DELAY = 32
ADR_ACK_LIMIT = 64
AckWindow = <class 'builtins.LorawanAckWindow'>
Acknowledgment = <class 'builtins.LorawanAcknowledgment'>
Backoff = <class 'builtins.LorawanBackoff'>
BackoffStep = <class 'builtins.LorawanBackoffStep'>
CfList = <class 'builtins.LorawanCfList'>
Channel = <class 'builtins.LorawanChannel'>
Delivery = <class 'builtins.LorawanDelivery'>
Device = <class 'builtins.LorawanDevice'>
DeviceError = <class 'pamoja.LorawanDeviceError'>
DeviceSettings = <class 'builtins.LorawanDeviceSettings'>
class Direction(builtins.str, enum.Enum):
301class Direction(str, enum.Enum):
302    """The direction a frame traveled, which its MIC and encryption fold in."""
303
304    #: From an end device up to the network.
305    UPLINK = "Uplink"
306    #: From the network down to an end device.
307    DOWNLINK = "Downlink"

The direction a frame traveled, which its MIC and encryption fold in.

EndDevice = <class 'builtins.LorawanEndDevice'>
Grant = <class 'builtins.LorawanGrant'>
Heard = <class 'builtins.LorawanHeard'>
JOIN_ACCEPT_DELAY1_US = 5000000
JOIN_ACCEPT_DELAY2_US = 6000000
JoinAccept = <class 'builtins.LorawanJoinAccept'>
JoinRequest = <class 'builtins.LorawanJoinRequest'>
Listen = <class 'builtins.LorawanListen'>
MAX_FCNT_GAP = 16384
MacCommand = <class 'builtins.LorawanMacCommand'>
class MessageType(builtins.str, enum.Enum):
284class MessageType(str, enum.Enum):
285    """What kind of message a frame is, read from its header."""
286
287    #: A device asking to join a network.
288    JOIN_REQUEST = "JoinRequest"
289    #: A network admitting a device.
290    JOIN_ACCEPT = "JoinAccept"
291    #: Data from a device that does not need acknowledging.
292    UNCONFIRMED_UP = "UnconfirmedUp"
293    #: Data from a device that asks to be acknowledged.
294    CONFIRMED_UP = "ConfirmedUp"
295    #: Data to a device that does not need acknowledging.
296    UNCONFIRMED_DOWN = "UnconfirmedDown"
297    #: Data to a device that asks to be acknowledged.
298    CONFIRMED_DOWN = "ConfirmedDown"

What kind of message a frame is, read from its header.

JOIN_REQUEST = <MessageType.JOIN_REQUEST: 'JoinRequest'>
JOIN_ACCEPT = <MessageType.JOIN_ACCEPT: 'JoinAccept'>
UNCONFIRMED_UP = <MessageType.UNCONFIRMED_UP: 'UnconfirmedUp'>
CONFIRMED_UP = <MessageType.CONFIRMED_UP: 'ConfirmedUp'>
UNCONFIRMED_DOWN = <MessageType.UNCONFIRMED_DOWN: 'UnconfirmedDown'>
CONFIRMED_DOWN = <MessageType.CONFIRMED_DOWN: 'ConfirmedDown'>
Next = <class 'builtins.LorawanNext'>
PackageCommand = <class 'builtins.LorawanPackageCommand'>
RECEIVE_DELAY1_US = 1000000
RECEIVE_DELAY2_US = 2000000
RECEIVE_WINDOW_TOLERANCE_US = 20
RETRANSMIT_TIMEOUT_MAX_US = 3000000
RETRANSMIT_TIMEOUT_MIN_US = 1000000
class ReceiveWindow(builtins.str, enum.Enum):
244class ReceiveWindow(str, enum.Enum):
245    """Which receive window a frame arrived in, as :meth:`EndDevice.heard` takes it.
246
247    TS001-1.0.4 section 4.1 has a device discard a frame whose MACPayload is longer than
248    the data rate it was received at carries, so a device told the window holds each frame
249    to that window's limit.
250    """
251
252    #: The first window, on the uplink's downlink channel.
253    RX1 = "rx1"
254    #: The second, on the fixed frequency and data rate.
255    RX2 = "rx2"
256    #: The relay window, which a device under a relay opens last, TS011-1.0.1 chapter 7.
257    RXR = "rxr"

Which receive window a frame arrived in, as EndDevice.heard() takes it.

TS001-1.0.4 section 4.1 has a device discard a frame whose MACPayload is longer than the data rate it was received at carries, so a device told the window holds each frame to that window's limit.

RX1 = <ReceiveWindow.RX1: 'rx1'>
RX2 = <ReceiveWindow.RX2: 'rx2'>
RXR = <ReceiveWindow.RXR: 'rxr'>
Relay = <class 'builtins.LorawanRelay'>
class RelayActivation(builtins.str, enum.Enum):
260class RelayActivation(str, enum.Enum):
261    """How an end device decides whether to send through a relay, TS011-1.0.1 section 10.2."""
262
263    #: Never, the default.
264    DISABLED = "disabled"
265    #: Always.
266    ENABLED = "enabled"
267    #: Only once a run of uplinks went unanswered, as the network's smart-enable level sets.
268    DYNAMIC = "dynamic"
269    #: However the device itself decides, which :meth:`EndDevice.use_relay` sets.
270    DEVICE_CONTROLLED = "device_controlled"

How an end device decides whether to send through a relay, TS011-1.0.1 section 10.2.

DISABLED = <RelayActivation.DISABLED: 'disabled'>
ENABLED = <RelayActivation.ENABLED: 'enabled'>
DYNAMIC = <RelayActivation.DYNAMIC: 'dynamic'>
DEVICE_CONTROLLED = <RelayActivation.DEVICE_CONTROLLED: 'device_controlled'>
RelayError = <class 'pamoja.LorawanRelayError'>
RelayExchange = <class 'builtins.LorawanRelayExchange'>
RelayHeard = <class 'builtins.LorawanRelayHeard'>
RelayStatus = <class 'builtins.LorawanRelayStatus'>
class RelaySync(builtins.str, enum.Enum):
273class RelaySync(str, enum.Enum):
274    """What an end device knows of its relay's scans, TS011-1.0.1 section 3.9."""
275
276    #: Nothing: no wake-on-radio frame has gone out yet.
277    INITIALIZED = "initialized"
278    #: One went out unanswered, so the next preamble spans a whole scan period.
279    UNSYNCHRONIZED = "unsynchronized"
280    #: It knows when the relay scans, so a short preamble reaches it.
281    SYNCHRONIZED = "synchronized"

What an end device knows of its relay's scans, TS011-1.0.1 section 3.9.

INITIALIZED = <RelaySync.INITIALIZED: 'initialized'>
UNSYNCHRONIZED = <RelaySync.UNSYNCHRONIZED: 'unsynchronized'>
SYNCHRONIZED = <RelaySync.SYNCHRONIZED: 'synchronized'>
RxData = <class 'builtins.LorawanRxData'>
SAVED_LEN = 1678
Scan = <class 'builtins.LorawanScan'>
Session = <class 'builtins.LorawanSession'>
Transmission = <class 'builtins.LorawanTransmission'>
class Version(builtins.str, enum.Enum):
230class Version(str, enum.Enum):
231    """A revision of the LoRaWAN link layer, as :class:`Backoff` takes it.
232
233    >>> backoff = Backoff(Version.V1_0_3)
234    >>> backoff.version
235    '1.0.3'
236    """
237
238    #: LoRaWAN 1.0.3.
239    V1_0_3 = "1.0.3"
240    #: TS001-1.0.4, the LoRaWAN 1.0.4 link layer.
241    V1_0_4 = "1.0.4"

A revision of the LoRaWAN link layer, as Backoff takes it.

>>> backoff = Backoff(Version.V1_0_3)
>>> backoff.version
'1.0.3'
V1_0_3 = <Version.V1_0_3: '1.0.3'>
V1_0_4 = <Version.V1_0_4: '1.0.4'>
Wake = <class 'builtins.LorawanWake'>
WakeUp = <class 'builtins.LorawanWakeUp'>
Window = <class 'builtins.LorawanWindow'>
WorNext = <class 'builtins.LorawanWorNext'>
def device(dev_eui: bytes, app_eui: bytes, app_key: bytes) -> LorawanDevice:
322def device(dev_eui: bytes, app_eui: bytes, app_key: bytes) -> LorawanDevice:
323    """Create a device holding the root credentials for over-the-air activation.
324
325    :param dev_eui: The 8-byte device EUI.
326    :param app_eui: The 8-byte application (join) EUI.
327    :param app_key: The 16-byte application key the join exchange is secured with.
328    :returns: The device, ready to build a join request.
329    :raises PamojaError: If any credential is the wrong length.
330    """
331    return LorawanDevice(bytes(dev_eui), bytes(app_eui), bytes(app_key))

Create a device holding the root credentials for over-the-air activation.

Parameters
  • dev_eui: The 8-byte device EUI.
  • app_eui: The 8-byte application (join) EUI.
  • app_key: The 16-byte application key the join exchange is secured with. :returns: The device, ready to build a join request.
Raises
  • PamojaError: If any credential is the wrong length.
def end_device( plan, dev_eui: bytes, join_eui: bytes, app_key: bytes, settings: LorawanDeviceSettings, fcnt_up: int = 0, fcnt_down: int | None = None) -> LorawanEndDevice:
426def end_device(
427    plan,
428    dev_eui: bytes,
429    join_eui: bytes,
430    app_key: bytes,
431    settings: LorawanDeviceSettings,
432    fcnt_up: int = 0,
433    fcnt_down: int | None = None,
434) -> LorawanEndDevice:
435    """Make an end device that joins over the air.
436
437    It owns no radio and no clock: every call takes the time in microseconds and hands
438    back what to put on the air, so the same device runs over any radio, or in a test
439    with none. :meth:`EndDevice.join` or :meth:`EndDevice.send` returns a transmission
440    and two receive windows; a frame heard in either goes to :meth:`EndDevice.heard`,
441    and :meth:`EndDevice.nothing_heard` says what to do when neither held one.
442
443    :param plan: A published channel plan, from ``pamoja.lora.plan_for`` or
444        ``pamoja.lora.cn470_plan``.
445    :param dev_eui: The 8-byte device EUI.
446    :param join_eui: The 8-byte join EUI.
447    :param app_key: The 16-byte root key.
448    :param settings: What the radio can do.
449    :param fcnt_up: The next uplink frame counter carried over a restart.
450    :param fcnt_down: The last downlink frame counter accepted, if any was.
451    :returns: The device, not yet joined.
452    :raises PamojaError: If the plan was built rather than published, or a credential is
453        the wrong length.
454
455    >>> from pamoja import lora
456    >>> node = end_device(lora.plan_for("EU868"), bytes(8), bytes(8), bytes(16), DeviceSettings(2, 14))
457    >>> request = node.join(1, 0)
458    >>> request.rx1.delay_us
459    5000000
460    """
461    return LorawanEndDevice.over_the_air(
462        plan,
463        LorawanDevice(bytes(dev_eui), bytes(join_eui), bytes(app_key)),
464        settings,
465        fcnt_up,
466        fcnt_down,
467    )

Make an end device that joins over the air.

It owns no radio and no clock: every call takes the time in microseconds and hands back what to put on the air, so the same device runs over any radio, or in a test with none. EndDevice.join() or EndDevice.send() returns a transmission and two receive windows; a frame heard in either goes to EndDevice.heard(), and EndDevice.nothing_heard() says what to do when neither held one.

Parameters
  • plan: A published channel plan, from pamoja.lora.plan_for or pamoja.lora.cn470_plan.
  • dev_eui: The 8-byte device EUI.
  • join_eui: The 8-byte join EUI.
  • app_key: The 16-byte root key.
  • settings: What the radio can do.
  • fcnt_up: The next uplink frame counter carried over a restart.
  • fcnt_down: The last downlink frame counter accepted, if any was. :returns: The device, not yet joined.
Raises
  • PamojaError: If the plan was built rather than published, or a credential is the wrong length.
>>> from pamoja import lora
>>> node = end_device(lora.plan_for("EU868"), bytes(8), bytes(8), bytes(16), DeviceSettings(2, 14))
>>> request = node.join(1, 0)
>>> request.rx1.delay_us
5000000
def grant( app_nonce: int, net_id: int, dev_addr: int, dl_settings: int = 0, rx_delay: int = 0, cflist: bytes | None = None) -> LorawanGrant:
367def grant(
368    app_nonce: int,
369    net_id: int,
370    dev_addr: int,
371    dl_settings: int = 0,
372    rx_delay: int = 0,
373    cflist: bytes | None = None,
374) -> Grant:
375    """Describe what this network grants a device that joined.
376
377    :param app_nonce: A nonce this network must not reuse for the device, since
378        the session keys are derived from it; low 24 bits only.
379    :param net_id: The network identifier; low 24 bits only.
380    :param dev_addr: The address to assign the device.
381    :param dl_settings: The downlink settings byte.
382    :param rx_delay: The delay before the first receive window, in seconds.
383    :param cflist: The optional 16-byte channel list.
384    :returns: The grant, which signs its own join-accept and derives the session.
385    :raises PamojaError: If the channel list is not 16 bytes.
386    """
387    return Grant(
388        app_nonce,
389        net_id,
390        dev_addr,
391        dl_settings,
392        rx_delay,
393        None if cflist is None else bytes(cflist),
394    )

Describe what this network grants a device that joined.

Parameters
  • app_nonce: A nonce this network must not reuse for the device, since the session keys are derived from it; low 24 bits only.
  • net_id: The network identifier; low 24 bits only.
  • dev_addr: The address to assign the device.
  • dl_settings: The downlink settings byte.
  • rx_delay: The delay before the first receive window, in seconds.
  • cflist: The optional 16-byte channel list. :returns: The grant, which signs its own join-accept and derives the session.
Raises
  • PamojaError: If the channel list is not 16 bytes.
def mac_parse( direction: Direction | str, data: bytes) -> list[LorawanMacCommand]:
405def mac_parse(direction: Direction | str, data: bytes) -> list[MacCommand]:
406    """Read the commands packed into a frame options field, or a port 0 payload.
407
408    The same identifier means a different command in each direction, so the direction
409    decides what is read and there is no default: 0x03 going down is a request to
410    change data rate, and the same byte coming up answers one.
411
412    A command does not carry its own length, so one this build does not know cannot be
413    stepped over. Reading stops there and returns what came before it.
414
415    Args:
416        direction: Which way the frame carrying them travels.
417        data: The options field, or the payload.
418
419    Returns:
420        The commands that were readable, in order.
421    """
422    name = direction.value if isinstance(direction, enum.Enum) else str(direction)
423    return _mac_parse(name, data)

Read the commands packed into a frame options field, or a port 0 payload.

The same identifier means a different command in each direction, so the direction decides what is read and there is no default: 0x03 going down is a request to change data rate, and the same byte coming up answers one.

A command does not carry its own length, so one this build does not know cannot be stepped over. Reading stops there and returns what came before it.

Args: direction: Which way the frame carrying them travels. data: The options field, or the payload.

Returns: The commands that were readable, in order.

def package_encode(port: int, kind: str, uplink: bool = False, **fields: object) -> bytes:
538def package_encode(port: int, kind: str, uplink: bool = False, **fields: object) -> bytes:
539    """Write one command of an application layer package.
540
541    Args:
542        port: Which package.
543        kind: Which command within it.
544        uplink: Which way it travels.
545        **fields: The fields that command carries; see :class:`PackageCommand`.
546
547    Returns:
548        The bytes it goes out as.
549
550    Raises:
551        PamojaError: If the port names no package, the name is not a command that package
552            defines, or a field the command needs is absent.
553    """
554    return _package_encode(PackageCommand(port, kind, uplink, **fields))

Write one command of an application layer package.

Args: port: Which package. kind: Which command within it. uplink: Which way it travels. **fields: The fields that command carries; see PackageCommand.

Returns: The bytes it goes out as.

Raises: PamojaError: If the port names no package, the name is not a command that package defines, or a field the command needs is absent.

def package_parse(port: int, uplink: bool, data: bytes) -> LorawanPackageCommand:
478def package_parse(port: int, uplink: bool, data: bytes) -> PackageCommand:
479    """Read one command of an application layer package.
480
481    A data fragment takes the whole message, as TS004-2.0.0 asks, and its bytes come back
482    on ``data``.
483
484    Args:
485        port: Which package: one of :data:`clock.PORT`, :data:`fragment.PORT`,
486            :data:`multicast.PORT` or :data:`firmware.PORT`.
487        uplink: Whether the frame carrying it traveled up.
488        data: The message, from this command's identifier on.
489
490    Returns:
491        The command that was read.
492
493    Raises:
494        PamojaError: If the port names no package, the message ends inside the command,
495            or the identifier is not one the package defines.
496    """
497    return _package_parse(port, uplink, data)

Read one command of an application layer package.

A data fragment takes the whole message, as TS004-2.0.0 asks, and its bytes come back on data.

Args: port: Which package: one of clock.PORT, fragment.PORT, multicast.PORT or firmware.PORT. uplink: Whether the frame carrying it traveled up. data: The message, from this command's identifier on.

Returns: The command that was read.

Raises: PamojaError: If the port names no package, the message ends inside the command, or the identifier is not one the package defines.

def package_parse_all(port: int, uplink: bool, data: bytes) -> list[LorawanPackageCommand]:
500def package_parse_all(port: int, uplink: bool, data: bytes) -> list[PackageCommand]:
501    """Read every command in one message.
502
503    A command does not carry its own length, so one this build does not know cannot be
504    stepped over. Reading stops there and returns what came before it.
505
506    Args:
507        port: Which package.
508        uplink: Whether the frame carrying them traveled up.
509        data: The message.
510
511    Returns:
512        The commands that were readable, in order.
513
514    Raises:
515        PamojaError: If the port names no package.
516    """
517    return _package_parse_all(port, uplink, data)

Read every command in one message.

A command does not carry its own length, so one this build does not know cannot be stepped over. Reading stops there and returns what came before it.

Args: port: Which package. uplink: Whether the frame carrying them traveled up. data: The message.

Returns: The commands that were readable, in order.

Raises: PamojaError: If the port names no package.

def package_status_item(data: bytes) -> LorawanPackageCommand:
520def package_status_item(data: bytes) -> PackageCommand:
521    """Read one group record of a multicast status answer.
522
523    The answer says how many groups follow; each is five bytes and carries no identifier
524    of its own, so they are read one at a time rather than by :func:`package_parse_all`.
525
526    Args:
527        data: The message, from the record on.
528
529    Returns:
530        The record, as an ``mc_group_status_item`` command.
531
532    Raises:
533        PamojaError: If the message ends inside the record.
534    """
535    return _package_status_item(data)

Read one group record of a multicast status answer.

The answer says how many groups follow; each is five bytes and carries no identifier of its own, so they are read one at a time rather than by package_parse_all().

Args: data: The message, from the record on.

Returns: The record, as an mc_group_status_item command.

Raises: PamojaError: If the message ends inside the record.

def parse_header(data: bytes) -> LorawanHeader:
334def parse_header(data: bytes) -> Header:
335    """Read a frame far enough to route it, without any key.
336
337    A receiver holding many sessions uses this to find which one a frame belongs
338    to: the device address travels in the clear, so it can be read before the
339    session that would verify the frame is even known.
340
341    Nothing this reports is authenticated. Treat it as a routing hint until
342    :meth:`Session.decode` has verified the frame.
343
344    :param data: The raw frame as it came off the radio.
345    :returns: What the header says the frame is.
346    :raises PamojaError: If the frame is truncated or carries a message type this
347        build does not read.
348    """
349    return _parse_header(bytes(data))

Read a frame far enough to route it, without any key.

A receiver holding many sessions uses this to find which one a frame belongs to: the device address travels in the clear, so it can be read before the session that would verify the frame is even known.

Nothing this reports is authenticated. Treat it as a routing hint until Session.decode() has verified the frame.

Parameters
  • data: The raw frame as it came off the radio. :returns: What the header says the frame is.
Raises
  • PamojaError: If the frame is truncated or carries a message type this build does not read.
def parse_join_request(data: bytes, app_key: bytes) -> LorawanJoinRequest:
352def parse_join_request(data: bytes, app_key: bytes) -> JoinRequest:
353    """Verify a join-request and read the identifiers out of it.
354
355    This is the network side of activation: it proves the request came from a
356    holder of the application key before reporting who sent it.
357
358    :param data: The raw join-request as it came off the radio.
359    :param app_key: The 16-byte application root key the device shares.
360    :returns: The verified request.
361    :raises PamojaError: If the MIC does not verify or the frame is not a
362        join-request.
363    """
364    return _parse_join_request(bytes(data), bytes(app_key))

Verify a join-request and read the identifiers out of it.

This is the network side of activation: it proves the request came from a holder of the application key before reporting who sent it.

Parameters
  • data: The raw join-request as it came off the radio.
  • app_key: The 16-byte application root key the device shares. :returns: The verified request.
Raises
  • PamojaError: If the MIC does not verify or the frame is not a join-request.
def session(dev_addr: int, nwk_skey: bytes, app_skey: bytes) -> LorawanSession:
310def session(dev_addr: int, nwk_skey: bytes, app_skey: bytes) -> LorawanSession:
311    """Create a session for a device already activated by personalization.
312
313    :param dev_addr: The device address the network assigned.
314    :param nwk_skey: The 16-byte network session key, which authenticates frames.
315    :param app_skey: The 16-byte application session key, which encrypts payloads.
316    :returns: The session, ready to encode and decode data frames.
317    :raises PamojaError: If either key is not 16 bytes.
318    """
319    return LorawanSession(dev_addr, bytes(nwk_skey), bytes(app_skey))

Create a session for a device already activated by personalization.

Parameters
  • dev_addr: The device address the network assigned.
  • nwk_skey: The 16-byte network session key, which authenticates frames.
  • app_skey: The 16-byte application session key, which encrypts payloads. :returns: The session, ready to encode and decode data frames.
Raises
  • PamojaError: If either key is not 16 bytes.