Spaces:
Running on Zero
M29 β LoRa Hardware Beacons
Spec version: v3.0 β experimental Depends on: M03 Capability Bus, M02 Transport, X02 Event Log, M11 Notifications, M01 Identity Depended on by: Civil Defense (M31) optionally consumes beacon-presence signals
1. Responsibility
Optional out-of-band presence and panic-button channel over 868 MHz LoRa hardware. When the internet is out, when the cellular network is down, when the power grid is wobbly β the LoRa stack still carries a 32-byte "I exist" ping or a tiny panic message between neighbours up to a few kilometres apart.
This module is explicitly not a data channel. No AI traffic, no chat content, no file transfer. The bandwidth is laughably small (sub-100 bytes per minute per node in a normal duty-cycle regime), the latency is awful, and the airwave is shared. What LoRa is good at is "I'm still here, and the gateway in the next village is still reachable."
The module exposes a small set of capabilities for sending and receiving beacons, mapping LoRa device IDs to HearthNet identities, and surfacing the resulting connectivity graph to the rest of the stack as a fallback signal. Hardware is a USB-attached LoRa stick (RFM95W, sx1276, sx1262 chipsets) bridged via serial.
2. Non-goals
- General-purpose meshing of HearthNet over LoRa. Bandwidth and duty cycle make this impossible at any useful scale.
- Encryption of beacon contents. Beacons carry identity hash + sequence + minimal flags only. Anything sensitive belongs on a different channel.
- Replacing TETRA/BOS. Emergency services have their own radio. This is a neighbour-to-neighbour fallback, not a replacement for professional emergency comms.
- Hardware abstraction layer for every LoRa chipset. v3.0 supports a small whitelist (RFM95W, sx1276, sx1262 via Meshtastic-firmware sticks). Others are open contributions.
- Long-distance routing. No multi-hop store-and-forward in v3.0. A beacon goes one hop or it doesn't go.
- Legal interpretation of national radio regulations. Each operator is responsible for complying with their local rules (BNetzA in Germany, ETSI in EU, FCC in US). The module enforces configured duty-cycle limits but cannot enforce the law on the operator.
3. File layout
hearthnet/lora/
βββ __init__.py
βββ service.py # LoraBeaconService β the capability handler
βββ serial_bridge.py # USB-serial framing to the LoRa stick
βββ frame.py # Encode/decode the 32-byte beacon frame
βββ duty_cycle.py # Track airtime, enforce duty-cycle limits
βββ peer_map.py # LoRa device ID β NodeID mapping (with TOFU verification)
βββ adapters/
βββ __init__.py
βββ meshtastic.py # Meshtastic firmware stick
βββ rfm95w.py # Bare RFM95W via serial-port gateway firmware
βββ sx126x.py # sx1262 module
4. Public API
4.1 Dataclasses
LoraBeaconID = NewType("LoraBeaconID", str) # device-local sequence + prefix
LoraDeviceID = NewType("LoraDeviceID", str) # hardware ID from the stick
@dataclass(frozen=True)
class LoraBeacon:
beacon_id: LoraBeaconID
sender_hash: bytes # 4-byte truncated SHA-256 of sender NodeID
sequence: int # u16, wraps
flags: int # u8 bit-field; see Β§5.2
rssi: int | None # dBm, on receive only
snr: float | None # dB, on receive only
timestamp: datetime # local clock at decode
@dataclass(frozen=True)
class LoraPeer:
node_id: NodeID
device_id: LoraDeviceID
sender_hash: bytes
first_seen: datetime
last_seen: datetime
rssi_recent: int | None
verified_tofu: bool # True after operator confirmation
@dataclass(frozen=True)
class DutyCycleStatus:
region: Literal["EU868","US915","AS923"]
window_seconds: int
airtime_used_ms: int
airtime_budget_ms: int # e.g. 36000 ms in EU868 1% window
next_tx_allowed_at: datetime
4.2 Capabilities
All under experimental.lora.*:
async def lora_status() -> LoraStatus
async def lora_beacon_send(flags: int = 0) -> LoraBeaconID
async def lora_panic_send() -> LoraBeaconID # sets FLAG_PANIC; bypasses normal pacing
async def lora_peer_list() -> list[LoraPeer]
async def lora_peer_verify(device_id: LoraDeviceID, node_id: NodeID) -> VerifyReceipt
async def lora_recent_beacons(since: datetime | None = None) -> list[LoraBeacon]
async def lora_duty_cycle() -> DutyCycleStatus
async def lora_subscribe_beacons() -> AsyncIterator[LoraBeacon]
4.3 Service class
class LoraBeaconService:
def __init__(self,
bus: CapabilityBus,
event_log: EventLog,
notifications: NotificationService,
identity: IdentityService,
config: LoraConfig): ...
async def start(self) -> None: ... # opens serial, begins RX loop
async def stop(self) -> None: ...
async def send_beacon(self, flags: int = 0) -> LoraBeaconID: ...
async def send_panic(self) -> LoraBeaconID: ...
async def on_frame_received(self, raw: bytes, rssi: int, snr: float) -> None: ...
async def _drain_rx(self) -> None: ...
def duty_cycle_status(self) -> DutyCycleStatus: ...
4.4 Serial bridge
class SerialBridge:
def __init__(self, port: str, baud: int = 115200, adapter: LoraAdapter = ...): ...
async def open(self) -> None: ...
async def close(self) -> None: ...
async def write(self, frame: bytes) -> None: ...
async def read(self) -> AsyncIterator[bytes]: ...
class LoraAdapter(Protocol):
"""Per-chipset/firmware framing rules."""
name: str
def encode_tx(self, payload: bytes) -> bytes: ...
def decode_rx(self, raw: bytes) -> tuple[bytes, int, float]: ... # payload, rssi, snr
def at_init_commands(self) -> list[bytes]: ...
5. Behaviour
5.1 Beacon frame
Strictly 32 bytes, big-endian:
offset size field
0 1 version (currently 0x01)
1 4 sender_hash (SHA-256(NodeID)[:4])
5 2 sequence (u16, wraps)
7 1 flags
8 1 reserved (0x00)
9 4 unix_seconds (sender's clock, u32; informational only)
13 19 payload (currently zero-padded; reserved for future use)
No payload content is carried beyond identity-hash + flags + clock. The flags field carries:
FLAG_PANIC = 0x01 # urgent attention requested
FLAG_OK = 0x02 # explicit "I'm fine" (operator pressed an OK button)
FLAG_GATEWAY = 0x04 # this node has an alternate transport currently up
FLAG_LOW_BATTERY = 0x08 # device-level low-battery indicator
FLAG_RESERVED_* = 0x10..0x80
Frames are not encrypted. Frames are not anonymous either β the sender hash is small enough to collide (4 bytes), but stable enough that a passive observer can correlate beacons from the same sender over time. This is documented and acceptable for the threat model: LoRa airwaves are observable by construction.
5.2 RX path
SerialBridgeyields raw frames as they arrive.LoraAdapter.decode_rxpeels off the chipset framing and returns the 32-byte payload + RSSI + SNR.service.on_frame_receivedvalidates: length == 32, version == 0x01, sender_hash plausibly maps to a known or unknown peer.- If
sender_hashmatches a verified peer inpeer_map, the beacon is recorded against that peer. - If
sender_hashis unknown, alora.peer.unknownevent is emitted with a TOFU verification prompt for the operator. - If
FLAG_PANICis set, a high-priority notification is raised via M11 regardless of peer-verification status. - The beacon is published on the bus subscription
experimental.lora.beacon.received.
5.3 TX path and duty cycle
Beaconing follows a fixed cadence LORA_BEACON_PERIOD_SECONDS (default 600 = 10 minutes). Each transmission's airtime is computed from spreading factor and bandwidth (typical: SF9, BW125 β ~165 ms per 32-byte frame) and added to the duty-cycle window.
The duty-cycle window enforces the region's regulation:
| Region | Window | Budget |
|---|---|---|
| EU868 | 3600 s | 36 s (1%) |
| US915 | 3600 s | unlimited (FHSS) but config still applies |
| AS923 | 3600 s | 36 s (1%) |
If a normal send_beacon call would exceed the budget, it is deferred until the budget allows. send_panic ignores the duty-cycle limit (regulations universally permit emergency transmissions). The operator is told via notification that the duty-cycle override was used and the event log records lora.duty_cycle.overridden.
5.4 Peer mapping (TOFU)
The first time a sender_hash is received, the module emits a notification: "A new LoRa peer with hash 0xABCD1234 was heard. Do you recognise this device?" The operator can:
- Verify by NodeID β provide a HearthNet NodeID; the module checks that
SHA-256(NodeID)[:4] == sender_hashand stores the verified mapping. - Mark as unknown β store the hash with no NodeID; future beacons from this hash will still be tracked but flagged unknown.
- Block β drop all beacons from this hash; never prompt again.
Hash collisions (two different NodeIDs producing the same 4-byte hash) are possible but unlikely. When two operators independently verify the same hash to different NodeIDs, the conflict is surfaced as a lora.peer.conflict event for manual resolution.
5.5 Beacon-presence signal
Other modules can subscribe to experimental.lora.beacon.received to incorporate "this peer is alive on LoRa even though the internet says they're offline" into their own logic. M31 Civil Defense in particular uses this to corroborate that a target node is alive during an outage incident.
The presence signal is advisory: a node that beacons on LoRa is alive in the radio sense, but that says nothing about whether the operator is responsive or whether higher-layer services are available there.
5.6 Failure modes
- No stick attached or USB error:
lora.status()reportsunavailable. The module starts in a disabled state; no errors are raised on startup, only logged. - Stick attached but firmware mismatch:
at_init_commandsfail; the adapter raiseslora_hardware_unsupportedand the service stays disabled. - Receive flood: the RX queue is bounded (
LORA_RX_QUEUE_MAXdefault 256). Overflow drops oldest entries and emits alora.rx.droppedevent. - Clock skew: beacons carry the sender's clock, but the receiver never trusts it for ordering β local arrival timestamp is authoritative.
- Adversarial flooding: an attacker on 868 MHz can spam frames; the duty-cycle limits us but not them. The service rate-limits beacons per
sender_hashat the RX side (LORA_PEER_RX_MAX_PER_MINUTE, default 20) to avoid filling notifications. Excess beacons from one hash are dropped silently after the rate limit; this is a known DoS vector and documented in Β§10.
6. Errors
| Code | When |
|---|---|
experimental_disabled |
Capability called with the flag off |
lora_hardware_unavailable |
No stick present or serial port not opened |
lora_hardware_unsupported |
Adapter init failed; firmware not whitelisted |
lora_duty_cycle_exhausted |
Non-panic send requested with budget at zero and override off |
lora_peer_unknown |
lora.peer.verify for a sender_hash we've never seen |
lora_peer_conflict |
verify() would create a (hash β two distinct NodeIDs) mapping |
lora_frame_malformed |
RX frame fails structural validation |
7. Configuration
@dataclass(frozen=True)
class LoraConfig:
enabled: bool = False
serial_port: str = "/dev/ttyUSB0" # also Windows COM4, etc.
serial_baud: int = 115200
adapter: Literal["meshtastic","rfm95w","sx126x"] = "meshtastic"
region: Literal["EU868","US915","AS923"] = "EU868"
spreading_factor: int = 9 # 7..12; higher = more range, less rate
bandwidth_khz: int = 125
coding_rate_denom: int = 5 # 4/5
tx_power_dbm: int = 14 # legal max for EU868
beacon_period_seconds: int = LORA_BEACON_PERIOD_SECONDS_DEFAULT # 600
panic_burst_count: int = 3 # PANIC sends this many frames rapid-fire
panic_burst_gap_ms: int = 800
rx_queue_max: int = LORA_RX_QUEUE_MAX # 256
peer_rx_max_per_minute: int = LORA_PEER_RX_MAX_PER_MINUTE # 20
tofu_auto_accept: bool = False # never auto-trust new hashes by default
duty_cycle_override_for_panic: bool = True
Constants live in hearthnet/constants.py.
8. Tests
8.1 Unit
test_frame_encode_decode_roundtripβ random payloads encode to exactly 32 bytes and round-trip.test_sender_hash_matches_nodeidβSHA-256(NodeID)[:4]matches the field in the encoded frame.test_duty_cycle_tracks_airtimeβ synthetic transmissions accumulate; budget drains; recovers over time.test_panic_overrides_duty_cycleβsend_panicsucceeds at zero budget when override is enabled.test_panic_blocked_when_override_disabledβsend_panicreturnslora_duty_cycle_exhaustedwhen override is off.test_peer_rx_rate_limitβ 30 frames from one hash within a minute β only 20 surface.
8.2 Integration (loopback)
- Mock
SerialBridgeechoes TX as RX after a configurable delay. Verify a sent beacon shows up inrecent_beaconsand on the subscription. - Two simulated nodes (separate SerialBridges connected via an in-memory channel) β A sends, B receives, B's peer_map contains A after TOFU verification, RSSI/SNR are populated.
8.3 Hardware-in-the-loop (optional)
- With a real LoRa stick, send N beacons and verify duty-cycle accounting matches what the firmware reports.
- Range test: two sticks at increasing distance; record packet-loss vs distance.
8.4 Negative
- Disabled flag β all capabilities return
experimental_disabled. - No serial port β
lora_hardware_unavailableon status. - Truncated frame β
lora_frame_malformed, dropped. - Conflicting verify β
lora_peer_conflict.
9. Cross-references
- Phase 1 M01 Identity β
SHA-256(NodeID)[:4]is the sender hash; verification uses M01's NodeID type. - Phase 1 M02 Transport β LoRa is not a Transport in the M02 sense. It does not carry capability-bus traffic; it lives parallel to M02 as an alternative signalling channel. The two share no code.
- Phase 1 M11 Notifications β high-priority panic-beacon notifications and TOFU prompts route through M11.
- Phase 1 X02 Event Log β
lora.*events. - Phase 3 M31 Civil Defense β beacon-presence is one corroborating signal for "is the target node alive" during an incident.
- Phase 3 X09 Conformance Suite β LoRa is an optional capability; conformance tests use a mock serial bridge.
10. Open research questions
Mesh routing. Multi-hop store-and-forward over LoRa is well-explored in the Meshtastic project. Whether HearthNet should adopt it (and inherit the bandwidth tradeoffs) or keep one-hop simplicity is unsettled. Probably belongs in M29b.
Authenticated beacons. Adding even a 4-byte MAC would let receivers reject forged sender-hashes. This costs payload space we don't have today. A 64-byte frame variant (
version 0x02) with HMAC-truncated-to-8-bytes is the obvious extension.DoS robustness. Per-hash rate limiting is naive; an attacker just rotates hashes. The defence on 868 MHz is mostly the regulatory duty-cycle and physical proximity, neither of which we control in software. Documented as a known limitation.
Sleep-and-wake duty cycles. Battery-powered nodes (a panic button by the bedside) want to sleep most of the time and wake on demand. Class-A/B/C LoRaWAN-style scheduling is the standard answer. Out of scope for v3.0.
Chipset coverage. v3.0 supports a small whitelist. Each new chipset is an adapter shaped exactly like the existing ones; contributors are encouraged.
GPS integration. Many LoRa sticks ship with a GPS module. We deliberately did not surface location data in v3.0 β location is privacy-sensitive and the use case is unclear. A future
FLAG_HAS_GPS+ paired side-channel might make sense for civil-defence scenarios.Integration with civil-defence radio. TETRA-BOS and BOS-Digitalfunk are professional networks we have no business interoperating with. But a unidirectional "did the BOS station broadcast a known alert" listener might be useful. Legally complex.
Network coding. When multiple nearby nodes beacon, the airwave fills. Cooperative beacon scheduling (so neighbours don't transmit on top of each other) is a fun problem. Currently each node beacons independently and collisions are accepted.
Last updated: spec v3.0.