mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-11 18:26:27 +10:00
412 lines
16 KiB
Dart
412 lines
16 KiB
Dart
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<int> imageChunkPayloadSizes(int payloadBytes) {
|
|
final count = imageChunkCount(payloadBytes);
|
|
if (count == 0) return const [];
|
|
final sizes = <int>[];
|
|
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 = <int>[];
|
|
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<int>(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,
|
|
);
|
|
}
|