Offload stations: the LP0 files from an SD card
Design for phase 42, asked for by Tim on 2026-10-15: the OpenCollar Edge firmware is gaining LP0, a raw LoRa path by which a tracker offloads its flash log to a nearby offload station, a RangerEdge with a battery board and an SD card. Several stations stand in an animal's home range (the case in hand: a CollarEdge on a pygmy hippo fixing every 15 minutes); when the tracker comes within range the records it has not offloaded yet go to the station's card. Protect must take the files from those cards and process them as it processes a raw log file: the records decoded, matched to the tracker, deduplicated against what came over the air, with the reception metadata kept. Decisions D319 to D324 were asked and taken on 2026-10-15, the recommended answer each time, with one correction from Tim the same day: the LP0 address and keys are provisioned per device, not per network, so they live on the device (D320). This document is the plan; O1 to O7 were built on 2026-10-15 (section 11 says where the build departed from the text), O8 is open.
1. What the firmware does, as the code reads
Read on 2026-10-15 from SmartParksOrg/smartparks-opencollar-edge-fw, branch fix/existing-lp0-implementation (68 commits ahead of main, which is at 8.0.3; the branch's changelog lists the work under Unreleased; the last commits of 2026-10-14 and 2026-10-15 add CMDQ cohabitation and the LoRaWAN, Bluetooth and satellite confirmation of offloaded records): app/src/lp0/README.md, lp0_offload/lp0_offload.c and .h (the station's SD writer), docs/reviews/lp0-wire-format.md (protocol version 4 of 2026-10-07), the master implementation plan's sections on device roles, record identity, addressing and the importer, docs/reviews/sd-card-primary-source-research.md, scripts/ttn_decoder.js (the port 23 and port 29 decoders), and the public smartparks-lp0-replay-app (make_test_log.py, the LoRaWAN 1.0 framing reference).
Roles. lp0_node_params[0] is the role: 0 off, 1 tracker, 2 station. Every supported device type can be a tracker; only a RangerEdge or RangerEdge AirQ with the Artekit AK-SDFS-UART reader (CONFIG_SDFS_UART_MODULE) can be a station. Trackers and stations share the discovery frequency and PHY (lp0_tx_frequency_hz, lp0_rx_frequency_hz, lp0_communication_params), the channel plan and the station count (lp0_node_params[2], [3]). Each tracker carries its own LP0 DevAddr, AppSKey and NwkSKey (lp0_dev_addr, lp0_app_key, lp0_network_key), provisioned per device like its DevEUI (Tim, 2026-10-15); the station learns the address during discovery. The tracker's DevEUI travels in discovery and in the station's logs, never in the SD file. The branch as read on 2026-10-15 calls the keys "common", authenticates DISCOVER with the station's own configured NwkSKey and rebuilds every saved frame with the station's configured keys (lp0_codec_replay_encode). Tim's firmware issue 772 of the same day ("LP0: separate master-key link authentication from per-device encrypted offload payloads") sets the direction this plan builds for: a deployment-wide lp0_master_key authenticates the link (discovery, DATA integrity, the receipts) through per-session keys derived from the discovery transcript; each collar encrypts its records before LP0 with its own unique DevAddr, NwkSKey and AppSKey under LoRaWAN frame counter and MIC semantics; the station treats the result as opaque bytes, may keep routing metadata (the device EUI or DevAddr, counters, times, lengths, the RF reception) and holds no device key; the backend identifies the device and decrypts with its unique credentials; the Semtech-style JSONL contract stays where feasible, versioned where it must change. Open in the issue, and so open here: whether the card holds complete LoRaWAN PHYPayloads or a versioned encrypted-record envelope, the exact counter semantics across transports, and which of the device's credentials are used (the issue names the existing LoRaWAN or satellite ones). Protect's side is built on the ordinary frame the station writes today and is ready for each open point as section 11 says.
A visit. The tracker sends DISCOVER on port 33 with its EUI and a fresh nonce; every idle station answers OFFER in its slot; the tracker keeps the strongest OFFER at or above the minimum RSSI (lp0_signal_checks, default -100 dBm, with a 20 dB margin over the background and a CAD veto), sends SELECT, gets ACCEPT with the final PHY, both switch to the station's own channel, and the tracker sends its flash records in DATA windows of up to five frames on port 29. The station writes every frame to the card at once and answers a saved-frame bitmap; the tracker marks the records of a confirmed frame as confirmed over LP0 (one bit per medium: satellite, Bluetooth, LoRaWAN, LP0) and the next visit resumes oldest first from the unconfirmed ones. A lost receipt means a repeated frame and a duplicate line on the card: the firmware does not deduplicate anywhere and says so ("deduplication belongs to later processing"). Three weak packets in a row end a visit; the rest waits for the next.
The card. Files live in numbered folders, LOG_1, LOG_2 and so on, 64 files per folder, named STATIONEUI_timestamp[_NNN].jsonl with the station's EUI in sixteen uppercase hex digits, the Unix second of the first frame's reception, and a collision suffix. One file takes every frame of every tracker whose session opened within 900 s of its first frame, capped at 4 GiB minus 5 MiB, so a file is a quarter of an hour of reception, not a tracker or a visit. Each line is the Semtech UDP rxpk document the replay app forwards to a network server:
{"gatewayEui": "0016C001F0ABCDEF", "rxpk": {"time": "2026-10-12T08:50:31Z", "tmst": 1791795031, "freq": 864.629000, "chan": 0, "rfch": 0, "stat": 1, "modu": "LORA", "datr": "SF7BW250", "codr": "4/5", "rssi": -71, "lsnr": 9, "size": 64, "data": "QAMCASYAZQAd..."}}
gatewayEui is the station, time and tmst the station's reception time (tmst is Unix seconds, not the microsecond counter the field means at a gateway), freq in MHz, datr the spreading factor and bandwidth, rssi in dBm and lsnr in dB as whole numbers, data the frame. The station strips the LP0 session envelope and rebuilds data as an ordinary LoRaWAN 1.0 uplink: MHDR 0x40, the tracker's DevAddr, FCtrl, a 16-bit frame counter (the upper half of the 32-bit counter is zero), FPort 29, the FRMPayload encrypted with the tracker's AppSKey, the MIC with its NwkSKey. The address is in the clear; nothing else is readable without the keys. The FRMPayload is the normal flash record stream [port][msg_id][len][data][store time u32] repeated, at most 228 bytes per frame, without the per-medium flags byte: the same bytes the port 29 uplink, the Bluetooth flash read and the raw log file carry, which _decode_flash_log in the OpenCollar driver already parses. Since firmware 8.1 a station also broadcasts a statistics message on port 23 (msg 0x9D, 99 bytes: the card detected and writable, the SD worker's state, uptime, 21 cumulative counters of visits, frames, bytes and errors) under its own DevAddr; idle stations archive each other's broadcasts to the card in the same JSON, and a station's own report may sit inside its port 29 records when its flash mask selects it.
The clocks. A record's store time is the tracker's clock, as in every flash stream. The line's reception time is the station's clock, which comes from a fix or from init_time plus uptime, so a station that never fixed writes a time years off.
What the firmware does not do. No record ids (dropped on 2026-10-02 in favour of the five-frame bitmaps), no tracker EUI in the file, no deduplication, no automatic upload: the card is carried to a computer. The research document's section 3.17 of 2026-09-03 describes an earlier LP0 and says to ignore it; it is superseded by this document and is rewritten in task O7.
The replay path that exists today. The replay app reads these files and forwards each line to a Semtech UDP gateway bridge, so a ChirpStack with the trackers registered as ABP devices under their own LP0 keys decodes the frames on port 29, and Protect's ChirpStack adapter would then receive them like any uplink. That path was considered and not chosen (section 2).
2. Why Protect reads the card itself
Protect could leave the cryptography to a network server: replay the card into a ChirpStack through the replay app and let the ChirpStack source deliver the frames. Three things speak against it. A network server enforces frame counters: a replayed card holds frames out of order and behind the counter the server last accepted, and the firmware's own plan calls the TTN replay procedure unqualified; a card that fails to replay would lose data silently. The LP0 keys of every tracker would live in a ChirpStack beside Protect's own copy, and the trackers would have to exist there twice (OTAA for the air, ABP for the cards), which TTN forbids and ChirpStack tolerates by making two devices of one. And nothing in Protect would know that a frame came from a card, from which station, with which reception, or that a card was read at all. Reading the file directly costs one small codec (section 5) and reuses the whole raw log file path: the managed asset with its status and counts, the file worker with its batches and progress, the decoder's flash stream path that keeps stored times, the canonical keys that fold a record delivered twice into one with two deliveries, and Needs attention for a tracker nobody named.
3. Decisions
| Id | Decision | Choice |
|---|---|---|
| D319 | An offload station is an entity with its device, and a gateway of the LP0 source | The RangerEdge is a device as today. It is assigned to an entity of the new Equipment sub-type Offload station (offload_station, icon device.lora_gateway), which gives it a name, a place set by hand (D261, the place of the device), a marker on the map, a picture and rules. Its EUI is also a gateway row of the LP0 data source (D320), created from the first frame's reception like a LoRaWAN gateway (D66) and linked to the device through a new gateways.device_id, so every offloaded frame is a reception with RSSI, SNR and frequency: the Gateways page, the coverage layer and the tracker's Connectivity tab work as they are. Over a gateway alone (nothing to name or alert on) and over an entity alone (no receptions, no coverage). Tim, 2026-10-15. |
| D320 | The LP0 address and keys live on the device; a built-in source carries the files and the identities | Every tracker has its own LP0 DevAddr, AppSKey and NwkSKey, provisioned per device as its DevEUI is. Protect keeps them per device: a table device_lp0_credentials (the address in the clear, the two keys as Fernet tokens through shared/secrets.py, who set them and when, and whether a person typed them or took them from the device's own settings readback), written through PUT /devices/{id}/lp0-credentials (devices:control in the device's project, or a server admin) and returned to such a caller, so the card shows them behind Show and the form pre-fills on Change; a viewer sees the address and "keys set" alone. Setting the address writes the device's external identity on a built-in data source "Offload station files" (adapter lp0_station, builtin, a fixed id as the log file and WebBLE sources have, decision D77), with identity_type dev_addr and the address as external id (eight uppercase hex digits as the firmware and an LNS print it, the big-endian form); every card's lines are deliveries on that source and every station is a gateway of it. A frame from an address no device has is retained unassigned and listed on Needs attention, where it is linked to the device, as an unknown DevEUI is (D96, D121), and its keys are entered on that device; "Create device" is offered too, with the keys to follow. Over a data source per LP0 network holding one pair of keys, which was the first proposal and which Tim corrected: the keys are unique per device. Tim, 2026-10-15. |
| D321 | The files are raw log files of the station, decoded through the one pipeline | A card's files are uploaded on the station's device page or on the built-in source's page, one JSONL at a time or a zip of the LOG_* folders; each JSONL becomes a device_log_files row with the station as its device, channel lp0 and the folder in its attributes (the same card, progress bar, counts, download and re-decode as a raw log; a zip is unpacked by the API into one row per file). The decoder's file worker reads a line, stores it as a source event of the built-in source with the DevAddr as external id, acquisition_channel lp0, ingestion_method file_upload, one gateway reception for the station, network_received_at from the line (D324) and file_uploaded_at from the row, and decodes it. The decryption is one step of the pipeline, not of the file worker: before the driver runs, a source event of channel lp0 without frame_hex is unwrapped by the adapter with the device's own credentials, which verifies the MIC with the device's NwkSKey and decrypts the payload with its AppSKey (LoRaWAN 1.0, the counter's upper half zero), writing frame_hex, f_port and mic_ok into the provider metadata (the payload, the line as stored, is never changed), so the driver's LoRaWAN path reads it; the same step serves the walk over retained frames after an address is linked (D121) and "Decode again". The OpenCollar driver decodes port 29 as the stored stream it is (records.stored, the records keep their stored times, decision D262) and port 23 as the statistics message (D323). A frame of a device without keys fails with CREDENTIALS_MISSING (user actionable: "set the LP0 keys on the device, then decode again"); a frame whose MIC does not verify fails with PAYLOAD_DECODE_FAILED ("the frame does not verify under this device's keys"), counted as malformed, never guessed. A frame with the same DevAddr, counter and bytes as one stored for this source in the last 30 days is the firmware's repeat after a lost receipt: stored as duplicate of the first and not decoded again, as a satellite redelivery is (D160); a record that also came over the air or from another card is one record with several deliveries through its canonical key. Recorded by Claude, 2026-10-15. |
| D322 | The reception metadata of every line is kept and shown | Per source event: the station EUI, the reception time, RSSI, SNR, the frequency, the spreading factor and bandwidth, the coding rate, the frame counter, the file and the line. The traffic tables' Details column reads them for channel lp0 (station, RSSI and SNR, SF and BW, the records in the frame); the source event dialog shows the line; the tracker's Connectivity tab gets an Offload block per LP0 source (stations heard in the period with frames and records each, the best station and its share, the last offload, repeated frames); the station's page gets an Offload station card (files received and their folders, frames and records, the trackers served in the last 30 days, the last visit, and the port 23 state). Tim, 2026-10-15: the metadata he asked for is the reception's. |
| D323 | The port 23 statistics message is decoded now | _decode_lp0_statistics in the OpenCollar driver: msg 0x9D, body length 97, version 1, the value and known bitmaps (an unobserved bit is unknown, never false), uptime, pending and high-water SD jobs, the 21 counters in the README's order; a state lp0_station with the eleven observations and measurements lp0_<counter> in a new metric category offload seeded in the registry; health lines "SD card" (critical when the card is absent or not writable, warn when unknown or when the storage queue is full) and "Offload visits"; a rule template "Offload station without a card". Golden against the branch's ttn_decoder.js on constructed frames until a recorded one exists. It also arrives over LoRaWAN when the station's send mask selects port 23. Over waiting for the release. Tim, 2026-10-15. |
| D324 | A station's reception time is believed only when it is plausible | A line's time earlier than LP0_EARLIEST_RECEPTION (2024-01-01) or later than the upload is a clock the station never set; the frame then carries no network_received_at and the clock rules of D119 judge the records against the upload time, as they judge a raw log file. The line's time is kept in the provider metadata either way. Without this a station without a fix would have every record of every tracker marked invalid as "ahead of its delivery". Recorded by Claude, 2026-10-15. |
4. The data flow of a card
- The credentials. On each tracker's device page, the LP0 card takes the address and the two keys (D320); saving writes the device's
dev_addridentity on the built-in source. A device whose settings readback carriedlp0_dev_addr,lp0_app_keyandlp0_network_key(the settings table of D228) offers "Use the device's reported values" with a confirmation, since the readback is the device's own word. The keys are shown and changeable by whoever may set them; a viewer sees only that they are set. - The upload.
POST /devices/{id}/log-filestakes a JSONL file or a zip on the station's device page (theLOG_*folders of a card, read withzipfilein the API, every*.jsonlmember a row, other members ignored and named in the response; a file whose name holds another station's EUI is accepted with a note);POST /data-sources/{id}/log-filestakes the same on the built-in source's page for a card whose station is not a device in Protect yet. Each row: the station from the file's name (its EUI, matched to a device by serial number or adev_euiidentity; a name that matches no device leavesdevice_idon the row's source and the station unknown, noted in the attributes), channellp0, the folder, the file's SHA-256 (the same file twice is refused with 409 as today). The limit staysLOG_FILE_MAX_BYTESper file and a zip of at mostLP0_ZIP_MAX_BYTES(512 MB). - The split.
parse_station_jsonlinshared/logfiles.py: one line is oneStationFrame(the station EUI, the reception time, RSSI, SNR, frequency, data rate, coding rate, the frame's bytes); a line that is not JSON, lacksrxpk.dataor whose base64 does not decode is an error with its line number; blank lines are skipped. The firmware writes every line with a leading newline, so the first line of a file is empty. - The frame.
shared/connectivity/adapters/lp0_station/lorawan.py:parse_uplink(MHDR must be0x40or0x80, DevAddr little-endian in the frame and reported big-endian, FCtrl with its FOpts length, the 16-bit counter, FPort, FRMPayload, MIC),verify_mic(AES-CMAC under the device's NwkSKey over B0 and the frame, the 32-bit counter with its upper half zero) anddecrypt_payload(AES-128 in the LoRaWAN counter construction under the device's AppSKey, or the NwkSKey on port 0). The file worker parses the header alone (the address and the counter are in the clear); the keys are applied in step 6. The golden test is the firmware's own fixture: the saved replay packet ofdocs/reviews/lp0-wire-format.mdunder its synthetic keys verifies and decrypts to the three-record stream the document gives (checked with thecryptographylibrary on 2026-10-15; no new dependency). - The delivery. An
InboundMessageper line:external_idthe DevAddr,identity_typedev_addr,event_typeuplink, the payload the line's document as stored (immutable, D8), the provider metadata withdev_addr,f_cnt,f_port,station_eui,received_at,rssi,snr,frequency_hz,data_rate,coding_rate,log_file_idandline; oneGatewayReceptionDatafor the station with RSSI, SNR and frequency (upsert_gatewaysmakes the gateway row on the built-in source, D66);network_received_atwhen plausible (D324);file_uploaded_at; nodevice_idfrom the caller, the identity decides.store_inboundresolves the identity, applies the repeat rule of D321 (_lp0_redeliverybeside_satellite_session: the same source, DevAddr, counter and payload digest within 30 days), writes the trace step "offload frame" with the station and the signal, and the file worker decodes the event in the same transaction as today,LOG_FILE_BATCH_SIZEframes or three seconds per commit. A line whose address no device has staysunassignedon the row, as any unknown identity's event does. - The unwrap and the decode.
process_source_eventruns the adapter'sunwrap_frame(event, credentials)for a channellp0event withoutframe_hex: the device's credentials are read (one read per device per batch), the MIC verified, the payload decrypted, andframe_hex,f_portandmic_okwritten to the provider metadata, with a trace step "frame unwrapped" naming the counter and the port. Then the OpenCollar driver treats channellp0as it treats LoRaWAN:f_port29 is the flash stream (_decode_flash_log,records.stored = True, the layout from the records' own status messages), 23 the statistics message (D323), another port a live message filed at the line's reception time.delivers_livestays LoRaWAN only, so D259's clock-behind rule does not touch a card. The pipeline attributes every record at its own time, links repeat deliveries to the canonical row, updates the tracker's current state only with what is newer than it (a card of last month moves nothing back), and publishesposition.createdfor records nothing had seen. - The counts. The row's
frames_total,frames_done,frames_failed(unreadable lines and frames that do not verify),records_found,records_new,records_duplicate, the period and the firmware as today; the attributes gaintrackers(per DevAddr: the device when known, frames, repeats, records new and known, andwithout_keyswhen the device has no credentials yet) andunknown_dev_addrs. The Needs attention summary counts the unknown identities of the source; "Link" to a device walks the retained frames through the decoder (D121), which unwraps each with that device's keys and lands the records; a device linked before its keys are set fails its frames withCREDENTIALS_MISSING, and "Decode again" on the file takes them up once the keys are there.
Bounds: a file of at most LOG_FILE_MAX_BYTES (64 MB, about 100,000 lines), a zip of at most 512 MB unpacked file by file, a batch per transaction, one reception per frame (the receptions hypertable is compressed by gateway; a card's receptions all belong to one station, so a card decompresses one segment), at most 32 records per frame by the wire format. A 15-minute window of one tracker at full rate is about 400 frames; the dev test's largest snapshot (6,607 records) is about 830 frames and 450 KB of JSONL.
5. Framework and core changes
- The adapter.
shared/connectivity/adapters/lp0_station/__init__.py(keylp0_station, label "Offload station files",pushfalse,builtintrue with a fixedSOURCE_IDcreated by migration 0053 as 0011 created the log file and WebBLE sources,acquisition_channelLP0, no credentials, no event connector, no command connector, asetup_hintsaying the keys live on each device,identity_typedev_addr,unwrap_frame(event, credentials)as the one place a frame is verified and decrypted) andlorawan.py(the codec of section 4, pure, typed, no ORM); registered inshared/connectivity/registry.py.AcquisitionChannel.LP0 = "lp0"and the frontend'schannelLabel("Offload station").tests/shared/test_provider_boundary.pykeeps holding: the adapter name lives only underadapters/. - The credentials per device. Migration 0053:
device_lp0_credentials(device id as primary key,dev_addrunique,app_s_keyandnwk_s_keyas Fernet tokens,set_by_user_id,set_at,sourcepersonordevice).shared/domain/lp0.pyholdsset_credentials(validates 8 and 32 hex digits, encrypts, writes or moves the device'sdev_addridentity on the built-in source throughensure_channel_identity's siblingensure_address_identity, refuses an address another device holds with a 409 naming it) andcredentials_for(session, device_id)(decrypts for the pipeline).PUTandDELETE /devices/{id}/lp0-credentials(devices:controlin the device's project, or a server admin; audited asdevice.lp0_credentials_set),GET /devices/{id}carrieslp0_dev_addrandlp0_keys_set;components/devices/Lp0CredentialsCard.tsxon the device page, with "Use the device's reported values" when the settings table holds the threelp0_*readbacks. - Identities by address.
ExternalIdentity.identity_typealready exists;resolve_identitytakes the message'sidentity_type; Needs attention's "Link" and "Create device" acceptdev_addridentities, the created device named after the address and left without keys until a person sets them;protect_api/serial.pyleaves the serial empty for an address, since a DevAddr is not a serial (D101). - The files.
DeviceLogFileunchanged but for the channel's check constraint (migration 0053 widensck_device_log_files_channelandck_source_events_*where the channel is checked),store_log_filewith channellp0, the zip path inrouters/log_files.py(upload_station_fileson the device and on the built-in source),parse_station_jsonlandStationFrameinshared/logfiles.py, thelp0branch offrame_messageand_process_newin the decoder'slogfiles.py, the unwrap step inprotect_decoder/pipeline.py(_unwrap_lp0, before the driver, the credentials cached per device for the batch),ErrorCode.CREDENTIALS_MISSING,_lp0_redeliveryinshared/ingest.py,LP0_EARLIEST_RECEPTIONandLP0_ZIP_MAX_BYTESinconfig.py. - The station. Migration 0053:
gateways.device_id(nullable,SET NULL), the catalogue rowoffload_stationunder Equipment withON CONFLICT DO NOTHINGas 0047 did, the metric seeds of D323.link_station_gatewayinshared/ingest.pyfillsdevice_idwhen a gateway on an LP0 source is created or synced and its external id equals a device's serial number or one of itsdev_euiidentities;gateway_readcarriesdevice_idanddevice_name; the Gateways page shows "Offload station" and links to the device; the map's gateway panel does the same.GET /devices/{id}/offloadanswers the Offload station card (files, frames, records, trackers served, last visit, the port 23 state) andcomponents/devices/OffloadStationCard.tsxshows it on the device page and on the entity page of an Offload station. - The driver.
_decode_lp0_statistics(D323), port 23 out ofNOT_CANONICAL_PORTS, thelp0channel indecode,HealthFieldentries for the card and the queue,explanations.pyentries, theoffload_station_no_cardtemplate with its Dutch,ERROR_FLAGSuntouched. - The reads.
_lp0_linkinrouters/devices.pybeside_lorawan_linkand_iridium_link(per LP0 source: the stations heard from the receptions of the period, frames and records per station from the source events, the best station's share, the last offload, repeats),SourceConnectivitywith anoffloadblock,components/devices/ConnectivityCard.tsxrenders it;TrafficRowcarries the LP0 metadata andTrafficTable.tsxdetails it; the source event dialog shows the line;lib/positionKind.tsis untouched (a record from a card is the device's own fix). - Docs.
docs/devices/offload-stations.md(what LP0 is in a paragraph, the station's setup in Protect, the keys and the addresses, reading a card, the counts and what a repeat is, the port 23 lines, the limits), the data source setup underdocs/integrations/lp0-station/(the adapter's runbook, as every adapter has), the pages guide, the driver rows, the research document's section 3.17 rewritten from the branch with the SD line format and the record layout,DEVELOPERS.md, ADR 0040 (offloaded frames are deliveries of a file data source, trackers by address, the station both entity and gateway), the changelog, this document's state.
6. Tests
tests/shared/test_lp0_lorawan.py: the firmware fixture of section 4 (the MIC verifies, the payload decrypts to the three records, a changed byte fails the MIC, the wrong key fails it), a frame built the waymake_test_log.pybuilds one round-trips, port 0 takes the NwkSKey, FOpts are skipped, a counter above 65,535 in the B0 block is never assumed.tests/shared/test_logfiles_lp0.py:parse_station_jsonlon a recorded file undertests/fixtures/payloads/lp0_station/(a synthetic card written with the fixture keys from the OpenCollar fixtures' real records, its README saying so; a real card's lines are ciphertext under real keys and would decode nothing in CI), the leading empty line, a bad line counted, the station EUI from the file name, a zip with two folders.tests/shared/test_opencollar_lp0.py:_decode_lp0_statisticsagainst the branch'sttn_decoder.jsvendored beside the other decoders with its commit named (scripts/opencollar_golden.pylearns the port), the unknown bits, an inconsistent gauge rejected; thelp0channel on port 29 keeps the stored times.tests/api/test_offload_stations.py: the credentials set on a device (the keys returned to those who may set them and not to a viewer, the address identity written, an address held by another device refused), a card uploaded as a zip through the station's device and a file through the built-in source, the rows and their progress, the identities on Needs attention and the walk after "Link", a device linked without keys failing withCREDENTIALS_MISSINGand "Decode again" after the keys are set, the records on the trackers at their stored times, a record delivered over LoRaWAN first and from the card second as one row with two deliveries, a repeated frame asduplicate, a frame under the wrong keys as failed, a station time of 1970 handled by D324, the gateway row with its receptions anddevice_id, the Offload block of the Connectivity tab, the Offload station card, the traffic details, the access matrix (devices:controlto upload and to set keys, as for a raw log; a member sees the trackers of their scope), the 409 on the same file twice.- Vitest:
channelLabel, the traffic details forlp0, the card. - By hand on dev: a copy of a card from the running test (section 10) uploaded, read against the station's own log of the same visit (the
tracker_euiandsave ACKlines) so the counts agree.
7. Phase 42 tasks
- [x] O1 the source and the credentials: the built-in
lp0_stationadapter with its fixed source id (migration 0053) andidentity_type,AcquisitionChannel.LP0, the registry, the frontend's channel label;device_lp0_credentials(migration 0053),shared/domain/lp0.py,PUTandDELETE /devices/{id}/lp0-credentials, the LP0 card on the device page with the readback offer,dev_addridentities on Needs attention; the provider boundary and the access matrix hold, the keys reach only those who may set them. - [x] O2 the codec:
lorawan.pywithparse_uplink,verify_micanddecrypt_payload, the adapter'sunwrap_frame, the firmware fixture as the golden test. - [x] O3 the files:
parse_station_jsonl, the zip upload on the device and the built-in source, channellp0ondevice_log_files(migration 0053's check), the file worker'slp0branch, the pipeline's unwrap step withCREDENTIALS_MISSING,_lp0_redelivery, D324's plausibility rule, the counts per tracker, the driver'slp0channel; the synthetic card fixture and its tests. - [x] O4 the station:
gateways.device_idand theoffload_stationcatalogue row (migration 0053),link_station_gateway, the Gateways page and the map panel naming the device,GET /devices/{id}/offloadand the Offload station card on the device and entity pages, Dutch. - [x] O5 port 23:
_decode_lp0_statistics, thelp0_stationstate, theoffloadmetrics seeded, the health lines, the explanations, the rule template, the vendored decoder and the golden test, Dutch. - [x] O6 the reads:
_lp0_linkand the Offload block of the Connectivity tab, the traffic details and the source event dialog forlp0, Dutch. - [x] O7 docs:
docs/devices/offload-stations.md, the adapter runbook, the pages guide, the driver rows, the research document's section 3.17,DEVELOPERS.md, ADR 0040, the changelog, this document's state. - [ ] O8 the dev server: the four trackers' addresses and keys set on their devices by Tim, the station as an Offload station entity with its place, a card's folders uploaded, read by Tim against the station's log; the run recorded here.
Order: O1 and O2 first (nothing decodes without an address, keys and a codec); O3 is the heart; O4 to O6 together; O7 with each; O8 last. Each task lands with its tests and docs, committed on Tim's word.
8. Exit criteria
- A copy of a real card's
LOG_*folders from the running test, uploaded on dev as one zip, gives one row per file, every frame verified and decrypted under its own tracker's keys, the trackers' records on their device pages at their stored times (positions on the map, the Data tab, the explorer), repeated frames counted as repeats, and the counts per file agreeing with the station's own log of those visits. - An unknown address shows on Needs attention with its retained frames; "Link" to a device with keys decodes them without a second upload, and a device without keys says so on its file row until they are set.
- A record that came over LoRaWAN and later from a card is one record with two deliveries, and the device's current position never moves back for a card.
- The station is an Offload station on the map with its place, a gateway with receptions on the Gateways page and in the coverage layer, linked to its device; a tracker's Connectivity tab names the stations that heard it; the traffic table shows the station, the signal and the rate per frame.
- A constructed port 23 message decodes to the station's state and health lines, golden against the firmware's decoder; a live one is read when the firmware ships.
- The access matrix holds for the new routes; a member scoped to some animals sees those trackers' frames and nothing else; the keys reach only those who may set them.
9. Later, not here
Stations that upload their own files (a RangerEdge with Wi-Fi or cellular posting to POST /data-sources/{id}/log-files, which the endpoint of O3 already allows with the source's webhook token); the per-medium confirmation flags of a record shown on the device (what the tracker believes was delivered, read from its port 29 records once the firmware sends them); commands to a station or a tracker through an LP0 command window (port 34); the LP0 settings (lp0_*) in the device settings tab with their firmware 8 families, which waits for Tim's guidance on the firmware 8 settings structure; S-band; a station's statistics as an analysis (visits per day, bytes per visit) in the device performance module; the replay app's path as an alternative inbound for a site that already runs it.
10. Inputs from Tim
- The firmware developer's change for issue 772, and what it did to the card's line: whether the frame stays an ordinary LoRaWAN 1.0 uplink under the tracker's own credentials (what Protect reads), or becomes a versioned envelope (then
unwrap_framelearns it by name), the counter width, and which of the device's credentials are used (its LP0 pair, or the LoRaWAN or satellite session keys). Tim waits for this change before testing (2026-10-15, end of day). - Then, on dev: the LP0 DevAddr, AppSKey and NwkSKey of each test tracker (SP052362, SP051830, SP052363, SP100557 and the replaced SP051883 per the firmware plan's reference table), set by Tim on each device's page; never in chat.
- A copy of a card's
LOG_*folders from the test, and the station's own log of the same period, for the by-hand check of section 6 and for a recorded fixture once its records are decoded (the fixture committed is the synthetic one). - The station's EUI and whether it has a place on dev; which project the test runs in.
- Whether the test firmware writes port 23 reports, so O5's golden can be a recorded frame.
- Confirmation of the catalogue placement: Offload station under Equipment beside Fence monitor and Trap, icon
device.lora_gateway.
11. As built, 2026-10-15
O1 to O7 landed in one session, in the order of section 7, after Tim's correction that the keys are per device. Where the build departs from the sections above:
- No upload on a data source page. A card is uploaded on the station's device page alone (
POST /devices/{id}/offload-files, from the Log files card, which takes a.jsonlor a zip beside a raw log). A row ofdevice_log_filesneeds a device, and the station is a RangerEdge with a DevEUI that is made as any device; a card of a station that is not a device yet waits for the device. The built-in source has no page of its own. - The station's own identity. A station's files create no
device_ididentity for the station on the offload source (as a raw log file does on the log file source): the station is a gateway of that source, not a device reporting on it. Its trackers' identities are by address. - The repeat rule keys the frame, not the line. The station writes the reception time into every line, so a repeated frame is a different line;
_lp0_repeatcompares the SHA-256 of the frame bytes (phy_sha256in the provider metadata) under the same address and source within 30 days. - Two status bits bear the decoder's names. The branch's
decodeLP0Statisticsnames bits 5 and 6lp0_enabledandlp0_listening, where the README saysenabledandlistening; the driver follows the code, which the test runs with node. - A bool health line can have a threshold.
lp0_sd_card_okis a yes or no that is critical when no;_level_ofinshared/domain/health.pylevels a bool bycritical_belowandwarn_belownow, which no earlier field used. - The walk's reception window. The decoder's walk over retained frames linked the receptions of an event within a day of its ingestion; a card's receptions lie weeks before its upload, so
unlinked_receptionstakes the oldest reception time of the page as well. - A station file's frames belong to other devices.
_existing_frames, which finds a file's stored frames for a re-decode and for resuming an interrupted run, matched on the file's device; for a station file it matches on the file alone. - The error code is in a check constraint.
application_errorschecks its codes, so migration 0053 admitsCREDENTIALS_MISSINGbeside thelp0channel ondevice_log_files,source_eventsandsource_deliveries. The migration ran in under a second on the local database with a compressed chunk; on the dev server the two hypertables are large and the constraint rebuild is the step to watch. - The metrics. The
offloadcategory seeds 25 metrics (the bool, uptime, the two SD gauges and the 21 counters) throughseed_sql, as 0048 seeded the physiology ones. - Checked.
ruff, the formatter andmypyclean;tests/shared,tests/decoder,tests/rulesandtests/api(244 passed) against the local compose stack; the frontend's type check, lint (the 21 warnings that were there, plus none), 262 Vitest tests, both catalogues complete (the Dutch written by hand for 64 strings); the strict docs build and the docs check; the OpenAPI schema regenerated.
Firmware issue 772, the same evening. Tim's issue sets the authentication model: a deployment master key for the link, per-device credentials for the payload, the station opaque, the backend decrypting with each device's own keys. The build already does the last three; three things were added for the issue's open points:
- A counter across a rollover. The frame carries the counter's low 16 bits; the issue asks for correct LoRaWAN counter semantics.
unwraptries the upper half zero (today's 16-bit mode), then the upper half of the device's last verified counter and the next one, as a LoRaWAN receiver does; the MIC decides and nothing else is guessed. The newest verified 32-bit counter is kept on the device's credentials (last_f_cnt,last_frame_at) and the frame's 32-bit counter in its metadata (f_cnt32). - A frame format named is a frame format checked. A line that names its format (
frameFormat,formatorlp0Format) is read only when it is one this version reads (lorawan); an envelope the firmware may introduce is refused by name, never guessed at, and the adapter'sunwrap_frameis the one place a second format would be added. - The tracker's EUI as the station's word. A line that carries the tracker's EUI (
devEui) keeps it on the frame and names the unknown identity with it on Needs attention, so a person sees which device an address belongs to; the identity stays the address and the keys decide, the EUI is never used to link by itself.
Which of the device's credentials the firmware will use (its own LP0 pair, or the LoRaWAN or satellite session keys) changes nothing in Protect but the words on the card: the three values set on the device are whatever the device encrypts its offloaded records with.
The Offload page, 2026-10-16. Tim asked for the per-project overview before a real card was read: GET /projects/{project_id}/offload and the Offload page under Network beside Gateways (O9 in the plan). It lists the stations (cards, receptions of the window, the own report's gist, the place), the trackers (address, keys set, the device's own reported address when it differs, frames, last station, last verified counter), the addresses no device has, and the count of devices without an address; it never carries a key, every row links to the device page where things are set. To be tuned on real data.
The page's setup, the same day. Tim: a guide and an easy way to turn existing devices into offload stations belong on this page, in one place. Found while assessing: a station set up by hand was invisible on the page until its first card, since the overview knew a station by its files, its gateway row or its port 23 report alone; the Offload station entity type was not read. Built (O10): POST /projects/{project_id}/offload/stations fills a station's gaps in one call and undoes nothing (the entity the device tracks is kept, else one named or a new Offload station entity; the place when given; the gateway row by hand through register_station_gateway, with nothing seen yet), the overview counts a device with an Offload station entity as a station, the dialog "Make an offload station" on the page (the project's devices by name or serial, the entity name, the place from the map, the coordinates or the browser's position), a Setup column per station and per tracker computed from the overview's fields, and "How to set up" with the three steps. What was proposed and left for the session with real data: a card drop zone on the page routed to its station by the EUI in the file name, a bulk credentials dialog over the project's trackers, the decode failures per tracker and station, the gap between an animal's newest LoRaWAN fix and its newest offloaded record, a station's counters over time, a stations layer on the live map, and a card inventory with its gaps.
LP0 v5, the same day (O12, decision D327). The firmware's branch moved to LP0 protocol version 5 (docs/reviews/lp0-master-key-wire.md, commit c2796b1 "sdfs change, master key"): a deployment lp0_master_key authenticates discovery and transport with keys derived by an SP 800-108 CMAC KDF, the collar builds the complete inner LoRaWAN frame itself (flash_interface.c calls lorawan_tools_build with its own lp0_dev_addr, lp0_app_key, lp0_network_key, port 29, the wrapping 16-bit allocator's value as a 32-bit counter with the upper half zero, unconfirmed), the station reassembles it from the DATA fragments and stores it unchanged, and prv_stringify in lp0_offload.c appends "lp0":{"version":5,"source_eui":...,"session":n,"window":n,"slot":n} beside rxpk when it has the metadata. Read against Protect: the frame is exactly what the pipeline decoded already (the design of D320 and D321 anticipated it), so the gaps were the lp0 object (unread; the parser looked for devEui and frameFormat, keys guessed on 2026-10-15 that the firmware never writes), no cross-check of the station-given EUI against the device the address belongs to, and no version gate. Built: _lp0_metadata in shared/logfiles.py (the five fields, a malformed object an error, an all-zero EUI none), the file worker carrying them in the provider metadata and naming an unknown identity by the source EUI, METADATA_VERSIONS on the adapter (5; absent is an older file; another refused by name), the pipeline's cross-check (device_eui in shared/domain/lp0.py, the serial or a dev_eui identity; a contradiction is DEVICE_IDENTITY_AMBIGUOUS, not retried, counted on the file row); no migration, no new error code. The counter order stands (upper half zero first, which is what the firmware signs; the rollover candidates kept for a receiver that would set them). For the firmware developer: a real v5 card for a golden test (the branch has only the v4 saved frame, whose bytes still validate the codec since the frame's shape did not change), and the 16-bit counter's wrap under unchanged keys, which reuses the keystream after 65536 frames and which Protect records and cannot repair.
Add a device, the same day (O11, decision D326). Tim: when the project has no devices yet or the RangerEdge is not a device, a button to add one belongs here, and a device should be added by scanning and connecting to it over Web Bluetooth, which also fetches its credentials, the LP0 ones included. Two decisions asked and the recommended answer taken: project admins create devices inside their project in one call (POST /projects/{project_id}/devices: the device at server level as every device is, assigned from now, with lp0 saved at once), where before only a server admin could create a device; and the Bluetooth path reads and saves everything (status and all settings: name, DevEUI as serial, hardware type, firmware, the LP0 address and keys through the catalogue served by device type, the frames synced to the new device as a Bluetooth log, a RangerEdge carrying on into the station setup). readIdentity in the browser's protocol module is the pure function behind it, tested; an all-zero EUI or key is the firmware's default and counts as not set. Tim, after the deploy: the separate "Add a device" button was wrong, adding the device is part of making a station; the form became the station dialog's first step ("A device of the project" or "A new device"), the setup carrying on with the device made.
Open from section 8 (O8): the dev server, Tim's keys and addresses on the four trackers, a real card against the station's log, and the firmware's answer to issue 772 (the frame on the card, the counter width, the credentials used).
The parser's home, 2026-10-17 (phase 43 P1, decision D331). parse_station_jsonl, StationFrame, _lp0_metadata, unpack_card_zip, looks_like_station_file and station_eui_from_name moved from shared/logfiles.py and shared/domain/lp0.py to shared/station_files.py, a module without a database, broker or settings import, so Smart Parks Protect Go (the companion for the place without a connection, its own repository) reads a card on a laptop with the same code. The references to shared/logfiles.py above are the history of 2026-10-15. Protect's side of Protect Go is phase 43 in PROJECT_PLAN.md; the field bundle endpoint is docs/devices/protect-go.md.
The first real cards, 2026-10-18 and 2026-10-19 (decisions D335 to D344). Luka's station SP052099 and its trackers in the LP0 project on dev, 835 files, read and read again after a clean start. What they taught, and what changed: a card is uploaded for a whole project and each file finds its station by the EUI in its name (D335); a card of a station the project does not have is not refused, the page makes the station and uploads again (D343); a station travels on a vehicle and is then mobile, placed by its own fix (D336); a project admin manages the devices of the project, since Luka had to be made server admin to add a station (D337); the trackers were not known when the cards arrived, so an unknown address is added as a tracker or a station, told apart by the ports its frames carry in the clear, with the address claimed before the keys (D338, D341), and a DevEUI identity links itself to the device with that serial while a name the network gives goes to Needs attention (D339); a station's clock ran a month behind, so a reception stamped before its record makes the upload the delivery (D340) and the page counts frames by arrival; the files a walk decodes after a device is added are summed anew (D342); a station's own report lists it as a station only while it listens, since a tracker's flash log carries the report too; a typed serial that contradicts the cards is flagged and corrected on the device page; a device made by mistake is renamed, or deleted, with its data by a server admin. Of the page functions proposed on 2026-10-16, the drop zone routed by EUI and the stations map are built; a bulk credentials dialog, the decode failures surfaced beyond the Setup column, the gap between an animal's newest LoRaWAN fix and its newest offloaded record, a station's counters over time and a card inventory with its gaps stay parked. Open from section 8 (O8) now: the counts of one card against the station's own log, and the firmware's word on the counter width (issue 772).
The firmware read again, 2026-10-20 (decision D345). The branch fix/existing-lp0-implementation at 7bae042, four commits past the c2796b1 of 2026-10-16: SD card presence checked once a minute on a healthy station, an autonomous storage recovery test that writes and reads back a root-level sdcheck.bin, LED patterns for the card's state, a station that offers no offloads while its storage is unhealthy, AirQ builds without LP0 (S-band alone, to fit their OTA partition), and a setup guide (docs/lp0-offload/README.md). Read against Protect: the card's line and the frame in it did not change (prv_stringify, the lp0 object, the LoRaWAN 1.0 frame under the tracker's keys, the 16-bit counter), so the codec, the parser and the pipeline stand; the port 29 flash stream's decoder now refuses a truncated record and keeps a final record with an empty body, which _decode_flash_log did already. What changed is the station's own report on port 23: version 2 (already at c2796b1, unread then) added the card's capacity in whole MB and its free share in whole percent in 104 bytes, version 3 (b79d2fa, 2026-10-19) added the status bits sd_card_missing and sdfs_error in the same bytes. Built: the driver reads versions 1, 2 and 3 and refuses another by name; lp0_sd_card_ok is one helper the decoder and the Offload page share (no when the station reports a missing card or a latched fault, even with the card bits unobserved); the metrics lp0_sd_capacity_mb and lp0_sd_free_percent (migration 0056) with a health line under 10 % free; the station card and the Offload page show the space and the two observations; the vendored decoder is the branch's at 7bae042 and the statistics test runs it over every version. sdcheck.bin needs nothing: the zip reader keeps only the LOG_*/*.jsonl members and Protect Go's volume walk only the station files. Still open in O8: a real card's counts against the station's log, a recorded port 23 frame as a golden (the tests construct them), and the firmware's answer on the counter width.