import 'dart:math' as math; import '../models/radio_settings.dart'; // image_chunk_transport is the single source of truth for chunk geometry: it is // what actually writes the bytes. This file used to declare rival constants // (payload 163 / header 2), which both disagreed with the wire format AND // collided by name -- any library importing both failed to compile. Import and // re-export instead, so there is exactly one declaration in the program. import '../services/image_chunk_transport.dart' show imageDataChunkCount, kImageChunkBlobBytes, kImageChunkCapacity, kImageChunkFirstCapacity, kImageChunkHeaderBytes, kImageChunkZeroMetadataBytes; export '../services/image_chunk_transport.dart' show imageDataChunkCount, kImageChunkBlobBytes, kImageChunkBodyBytes, kImageChunkCapacity, kImageChunkFirstCapacity, kImageChunkHeaderBytes, kImageChunkZeroMetadataBytes, kImageParityLengthBytes; /// Extra on-air bytes added by the MeshCore transport/routing header around a /// GRP_DATA packet. /// /// This value is NOT derivable from this repository, so it defaults to 0 and /// the resulting airtime is therefore a lower bound for the chunk frame itself /// (header + payload). Callers may pass a measured value to /// [estimateSend] once the real overhead is known. Do not guess it here. const int kMeshCoreOnAirOverheadBytes = 0; /// MeshCore MAX_TRANS_UNIT — the largest packet that can go on air. /// Useful as a worst-case airtime reference. const int kMeshCoreMaxTransUnit = 255; /// Standard LoRa time-on-air. /// /// ## Relationship to `connector/meshcore_protocol.dart:calculateLoRaAirtime()` /// /// That function exists and is used for *retry timeouts*; it is deliberately NOT /// reused here, and the two must not be conflated: /// /// * **Coding-rate domain.** `calculateLoRaAirtime()` takes the coding rate in /// the firmware's 1..4 domain and computes `(codingRate + 4)` internally. /// This file takes it in the 5..8 domain, matching [LoRaCodingRate.value] /// (`cr4_5 == 5`). A raw device value may arrive in *either* domain, so /// [normalizeCodingRate] maps 1..4 -> 5..8 before use. Passing a 5..8 value /// to `calculateLoRaAirtime()` would silently overstate airtime by up to 60% /// (it would compute CR 4/9..4/12), and passing a 1..4 value to /// [loraTimeOnAir] trips its assert in debug and understates airtime in /// release. Always normalise at the boundary. /// * **Low-data-rate optimise.** `calculateLoRaAirtime()` takes `DE` as a /// parameter and its one caller passes the `sf >= 11` shortcut, which is /// wrong for SF11/BW250 and SF12/BW500 (Tsym is 8.192 ms there, below the /// 16 ms threshold). This file derives `DE` from `Tsym > 16 ms`, per the /// datasheet. See the LDRO tests in `test/lora_airtime_test.dart`. /// * **Precision and guards.** `calculateLoRaAirtime()` returns whole /// milliseconds and will throw `Unsupported operation: Infinity or NaN /// toInt` on `sf == 0` / `bw == 0`, which a half-initialised device does /// report. This file keeps microseconds (2 packets x rounding is visible in /// a 1-2 s figure) and forces callers through [areLoRaParamsValid]. /// /// The *flood cost* model in `calculateMessageTimeout()` — `500 ms + 16 x /// airtime` — is a retry deadline, i.e. a deliberate upper bound on when a reply /// could still arrive. It is not a wall-clock estimate and is not reused as one; /// see [kImageSendChunkGapBase] for the pacing model this file uses instead. /// /// Tsym = 2^SF / BW /// lowDataRateOptimize (DE) = 1 if Tsym > 16 ms else 0 /// payloadSymbols = max(ceil((8*PL - 4*SF + 28 + 16*CRC - 20*IH) /// / (4*(SF - 2*DE))) * (CR + 4), 0) /// ToA = (preamble + 4.25) * Tsym + (8 + payloadSymbols) * Tsym /// /// [codingRate] is given in the 5..8 domain (5 = 4/5 … 8 = 4/8), matching /// [LoRaCodingRate.value]. If you hold a raw firmware coding rate, normalise it /// with [normalizeCodingRate] first. /// /// The result is returned at microsecond precision; no rounding to whole /// milliseconds is applied. Duration loraTimeOnAir({ required int payloadBytes, required int spreadingFactor, required int bandwidthHz, required int codingRate, int preambleSymbols = 8, bool crc = true, bool explicitHeader = true, }) { assert(spreadingFactor >= 5 && spreadingFactor <= 12); assert(bandwidthHz > 0); assert(codingRate >= 5 && codingRate <= 8); final pl = math.max(payloadBytes, 0); final sf = spreadingFactor; // Symbol time in milliseconds. final tsymMs = (1 << sf) / (bandwidthHz / 1000.0); // Low data rate optimisation is mandated when a symbol lasts over 16 ms. final de = tsymMs > 16.0 ? 1 : 0; final ih = explicitHeader ? 0 : 1; final numerator = 8 * pl - 4 * sf + 28 + 16 * (crc ? 1 : 0) - 20 * ih; final denominator = 4 * (sf - 2 * de); final payloadSymbols = math.max( (numerator / denominator).ceil() * codingRate, 0, ); final preambleMs = (preambleSymbols + 4.25) * tsymMs; final payloadMs = (8 + payloadSymbols) * tsymMs; return Duration(microseconds: ((preambleMs + payloadMs) * 1000).round()); } /// Normalises a raw firmware coding rate into the 5..8 domain used by /// [loraTimeOnAir] and [LoRaCodingRate.value]. Some firmwares report 1..4. int normalizeCodingRate(int deviceCodingRate) => deviceCodingRate <= 4 ? deviceCodingRate + 4 : deviceCodingRate; /// Whether [loraTimeOnAir] can safely be called with these parameters. /// /// The radio values reaching us are raw bytes off the wire (`currentSf`, /// `currentBwHz`, `currentCr`), so a disconnected or half-initialised device /// can hand us zeroes. Those are not merely wrong, they are fatal: `sf == 0` /// drives the `4 * (SF - 2*DE)` denominator to zero and the subsequent /// `.ceil()` throws "Unsupported operation: Infinity or NaN toInt", and /// `bandwidthHz == 0` makes the symbol time infinite. Asserts alone do not help /// in release builds, so callers must gate on this. /// /// [codingRate] is checked in the 5..8 domain; normalise first. bool areLoRaParamsValid({ required int? spreadingFactor, required int? bandwidthHz, required int? codingRate, }) { if (spreadingFactor == null || bandwidthHz == null || codingRate == null) { return false; } if (spreadingFactor < 5 || spreadingFactor > 12) return false; if (bandwidthHz <= 0) return false; if (codingRate < 5 || codingRate > 8) return false; return true; } /// Fixed part of the gap the sender must leave between two chunk packets. /// /// Raw airtime is only the time our own transmitter is modulating. A multi-chunk /// image is paced: the app hands chunk *i+1* to the companion radio only after /// chunk *i* has been queued, transmitted and the local channel has cleared, so /// wall clock is substantially longer than the sum of the airtimes. Two numbers /// in this repository bound that gap: /// /// * `connector/meshcore_protocol.dart:calculateMessageTimeout()` uses a /// **500 ms base delay** for "the companion radio has dealt with this /// packet", independent of airtime. That is where this constant comes from. /// * MeshCore repeaters default to a flood retransmit spacing of **0.5 x /// airtime** (see `repeater_txDelayHelper`), and a packet is retransmitted by /// every repeater in range, so at least one full airtime of quiet is needed /// before the next chunk to avoid colliding with the first hop. That is /// [kImageSendChunkGapAirtimeFactor]. /// /// This is a MODEL, not a measurement: confirming it needs real radios, which is /// out of scope here. It is deliberately derived from numbers already shipped in /// this app rather than invented, and it is applied only *between* packets, so a /// single-chunk send shows raw airtime unchanged. const Duration kImageSendChunkGapBase = Duration(milliseconds: 500); /// Airtime-proportional part of the inter-chunk gap. See /// [kImageSendChunkGapBase]. const double kImageSendChunkGapAirtimeFactor = 1.0; /// The pacing gap left after transmitting a packet whose airtime is /// [packetAirtime], before the next chunk is queued. Duration imageSendChunkGap(Duration packetAirtime) => Duration( microseconds: kImageSendChunkGapBase.inMicroseconds + (packetAirtime.inMicroseconds * kImageSendChunkGapAirtimeFactor).round(), ); /// Result of estimating an image (or any chunked payload) send. /// /// [perPacketAirtime] and [totalAirtime] are null when the radio parameters are /// unknown — in that case the packet count is still meaningful and should be /// shown, but no airtime may be fabricated. class SendEstimate { /// Number of packets that will be transmitted, including the parity packet /// when [includesParity] is true. final int chunkCount; /// Total on-air bytes across all packets (chunk headers + payload + any /// configured transport overhead), including the parity packet. final int totalBytes; /// Airtime of one maximally-filled chunk packet, or null if the radio /// parameters are unknown. final Duration? perPacketAirtime; /// Sum of the airtime of every packet actually sent, or null if the radio /// parameters are unknown. /// /// This is transmitter occupancy only. For anything the user is asked to wait /// for, show [pacedWallClock] instead. final Duration? totalAirtime; /// Realistic wall clock for the whole send: [totalAirtime] plus the /// inter-chunk pacing gap ([imageSendChunkGap]) after every packet but the /// last. Null exactly when [totalAirtime] is null. /// /// Equal to [totalAirtime] for a single-packet send. final Duration? pacedWallClock; /// Whether a XOR parity packet is included in [chunkCount] / [totalBytes]. final bool includesParity; const SendEstimate({ required this.chunkCount, required this.totalBytes, required this.perPacketAirtime, required this.totalAirtime, required this.pacedWallClock, required this.includesParity, }); /// True when airtime could be computed (radio parameters were known). bool get hasAirtime => totalAirtime != null; @override String toString() => 'SendEstimate(chunks: $chunkCount, bytes: $totalBytes, ' 'perPacket: $perPacketAirtime, total: $totalAirtime, ' 'wallClock: $pacedWallClock, parity: $includesParity)'; @override bool operator ==(Object other) => other is SendEstimate && other.chunkCount == chunkCount && other.totalBytes == totalBytes && other.perPacketAirtime == perPacketAirtime && other.totalAirtime == totalAirtime && other.pacedWallClock == pacedWallClock && other.includesParity == includesParity; @override int get hashCode => Object.hash( chunkCount, totalBytes, perPacketAirtime, totalAirtime, pacedWallClock, includesParity, ); } /// Number of data chunks needed for [payloadBytes], excluding parity. /// /// Delegates to the transport so the estimate shown in the preview can never /// disagree with what the chunker actually emits. Differs from /// [imageDataChunkCount] in one respect only: a zero-byte payload needs zero /// chunks here (there is nothing to estimate), whereas the transport still /// emits one chunk so a receiver can observe an empty image. int imageChunkCount(int payloadBytes) { if (payloadBytes <= 0) return 0; return imageDataChunkCount(payloadBytes); } /// Payload bytes carried by each data chunk, in order. List imageChunkPayloadSizes(int payloadBytes) { final count = imageChunkCount(payloadBytes); if (count == 0) return const []; final sizes = []; var remaining = payloadBytes; for (var i = 0; i < count; i++) { final capacity = i == 0 ? kImageChunkFirstCapacity : kImageChunkCapacity; final take = remaining < capacity ? remaining : capacity; sizes.add(take); remaining -= take; } return sizes; } /// Estimates packet count and airtime for sending [payloadBytes] of chunked /// image data. /// /// [radio] may be null (radio settings not yet read from the device). In that /// case the packet count and byte totals are still returned but both airtime /// fields are null — no default SF/BW is substituted, because a fabricated ETA /// is worse than none for a feature whose purpose is informed consent. /// /// [parity] adds exactly one XOR parity packet (GRP_DATA is unacknowledged, so /// parity allows recovery of a single lost chunk). No parity packet is added /// for an empty payload. SendEstimate estimateSend({ required int payloadBytes, required RadioSettings? radio, bool parity = true, int onAirOverheadBytes = kMeshCoreOnAirOverheadBytes, }) { return estimateSendFromRadioParams( payloadBytes: payloadBytes, spreadingFactor: radio?.spreadingFactor.value, bandwidthHz: radio?.bandwidth.hz, codingRate: radio?.codingRate.value, parity: parity, onAirOverheadBytes: onAirOverheadBytes, ); } /// Same as [estimateSend] but takes the raw, individually-nullable radio /// parameters exposed by the connector (`currentSf`, `currentBwHz`, /// `currentCr`). [codingRate] may be in either the 1..4 or 5..8 firmware /// encoding; it is normalised via [normalizeCodingRate]. SendEstimate estimateSendFromRadioParams({ required int payloadBytes, required int? spreadingFactor, required int? bandwidthHz, required int? codingRate, bool parity = true, int onAirOverheadBytes = kMeshCoreOnAirOverheadBytes, }) { final payload = math.max(payloadBytes, 0); final sizes = imageChunkPayloadSizes(payload); final dataChunks = sizes.length; final withParity = parity && dataChunks > 0; // On-air bytes per packet: chunk header + payload (+ chunk 0 metadata) // + any transport overhead. // On-air blob layout, taken from buildImageChunks() rather than assumed: // data chunk : header + body (chunk 0's body opens with metadata) // parity : header + len byte + a FULL kImageChunkBodyBytes XOR body // The parity-length byte belongs to the PARITY chunk only — charging it to // every data chunk, and sizing parity from the largest data body, understated // a 110-byte payload as 232 on-air bytes when the real total is 278. final packetBytes = []; for (var i = 0; i < sizes.length; i++) { final meta = i == 0 ? kImageChunkZeroMetadataBytes : 0; packetBytes.add( kImageChunkHeaderBytes + meta + sizes[i] + onAirOverheadBytes, ); } if (withParity) { // Always the full blob: the XOR body is zero-padded to kImageChunkBodyBytes // regardless of how short the data chunks are. packetBytes.add(kImageChunkBlobBytes + onAirOverheadBytes); } final totalBytes = packetBytes.fold(0, (a, b) => a + b); final chunkCount = packetBytes.length; // Only non-null AND in-range parameters are safe: see [areLoRaParamsValid]. // Anything else yields an estimate with packet counts but no airtime, which // the UI must render as "unknown" rather than fabricating a number. final cr = codingRate == null ? null : normalizeCodingRate(codingRate); final known = areLoRaParamsValid( spreadingFactor: spreadingFactor, bandwidthHz: bandwidthHz, codingRate: cr, ); if (!known) { return SendEstimate( chunkCount: chunkCount, totalBytes: totalBytes, perPacketAirtime: null, totalAirtime: null, pacedWallClock: null, includesParity: withParity, ); } // Safe to force: areLoRaParamsValid() above proved all three are non-null // and in range. Duration airtimeFor(int bytes) => loraTimeOnAir( payloadBytes: bytes, spreadingFactor: spreadingFactor!, bandwidthHz: bandwidthHz!, codingRate: cr!, ); // Airtime of a maximally-filled chunk packet, i.e. the cost of one "typical" // packet in the stream. A full blob is the whole kImageChunkBlobBytes. final perPacket = airtimeFor(kImageChunkBlobBytes + onAirOverheadBytes); var totalMicros = 0; // Wall clock adds a pacing gap after every packet except the last, so a // single-packet send is unaffected. See [kImageSendChunkGapBase]. var wallClockMicros = 0; for (var i = 0; i < packetBytes.length; i++) { final airtime = airtimeFor(packetBytes[i]); totalMicros += airtime.inMicroseconds; wallClockMicros += airtime.inMicroseconds; if (i != packetBytes.length - 1) { wallClockMicros += imageSendChunkGap(airtime).inMicroseconds; } } return SendEstimate( chunkCount: chunkCount, totalBytes: totalBytes, perPacketAirtime: perPacket, totalAirtime: Duration(microseconds: totalMicros), pacedWallClock: Duration(microseconds: wallClockMicros), includesParity: withParity, ); }