Spaces:
Running on Zero
Running on Zero
File size: 17,817 Bytes
70650b7 | 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 | # M29 β LoRa Hardware Beacons
**Spec version:** v3.0 β *experimental*
**Depends on:** [M03 Capability Bus](../../modules/M03-capability-bus.md), [M02 Transport](../../modules/M02-transport.md), [X02 Event Log](../../cross-cutting/X02-events.md), [M11 Notifications](../../modules/M11-notifications.md), [M01 Identity](../../modules/M01-identity.md)
**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
```python
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.*`:
```python
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
```python
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
```python
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:
```python
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
1. `SerialBridge` yields raw frames as they arrive.
2. `LoraAdapter.decode_rx` peels off the chipset framing and returns the 32-byte payload + RSSI + SNR.
3. `service.on_frame_received` validates: length == 32, version == 0x01, sender_hash plausibly maps to a known or unknown peer.
4. If `sender_hash` matches a verified peer in `peer_map`, the beacon is recorded against that peer.
5. If `sender_hash` is unknown, a `lora.peer.unknown` event is emitted with a TOFU verification prompt for the operator.
6. If `FLAG_PANIC` is set, a high-priority notification is raised via M11 regardless of peer-verification status.
7. 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_hash` and 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()` reports `unavailable`. The module starts in a disabled state; no errors are raised on startup, only logged.
- **Stick attached but firmware mismatch:** `at_init_commands` fail; the adapter raises `lora_hardware_unsupported` and the service stays disabled.
- **Receive flood:** the RX queue is bounded (`LORA_RX_QUEUE_MAX` default 256). Overflow drops oldest entries and emits a `lora.rx.dropped` event.
- **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_hash` at 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
```python
@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_panic` succeeds at zero budget when override is enabled.
- `test_panic_blocked_when_override_disabled` β `send_panic` returns `lora_duty_cycle_exhausted` when override is off.
- `test_peer_rx_rate_limit` β 30 frames from one hash within a minute β only 20 surface.
### 8.2 Integration (loopback)
- Mock `SerialBridge` echoes TX as RX after a configurable delay. Verify a sent beacon shows up in `recent_beacons` and 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_unavailable` on 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
1. **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.
2. **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.
3. **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.
4. **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.
5. **Chipset coverage.** v3.0 supports a small whitelist. Each new chipset is an adapter shaped exactly like the existing ones; contributors are encouraged.
6. **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.
7. **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.
8. **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.*
|