The “adaptive” part of the system has three pieces:
These run the protocol path (Vibration, oversized Sound messages) and the Simulation Lab. The Light and Sound fountain paths don’t need them, because they are rateless.
Back to the documentation index.
| Situation | Path | Adaptive switching? |
|---|---|---|
| Light message (any size) | Fountain, direct | No: the user picked Light |
| Sound message ≤ 8 KiB | Fountain, direct | No |
| Sound message > 8 KiB | Protocol (TransferManager, legacy FSK) |
Forced channel |
| Vibration message | Protocol | Forced channel |
| Legacy Messages screen, Hardware screen | Protocol | Optional |
| Simulation Lab, Performance comparison | Protocol over simulated channels | Yes |
In the everyday Send/Receive screens the user chooses the channel, so the engine’s switching is demonstrated mainly in the Simulation Lab.
lib/core/manager/transfer_manager.dart:
idle
└─► discovering _discoverPeer (unicast) — beacons until a discoveryResponse arrives
└─► testingChannels channelManager.testAll(testPacketCount = 20) → score each
└─► negotiating choose the channel:
forced (user) → score 1.0
broadcast → optical, else acoustic, else first
hardware + acoustic available → acoustic (0.9)
otherwise → decisionEngine.selectBestChannel
└─► transferring ReliableTransport + _transferLoop
├─ every 20 iterations: _evaluateSwitch
│ degraded → testing alternatives → maybe switchingChannel → recovering
└─► completed / failed ─► idle
Transport configuration:
| Mode | ackTimeoutMs |
maxRetries |
windowSize |
Packet size | Loop delay | Max iterations |
|---|---|---|---|---|---|---|
| Simulation | 500 | 5 | 8 | 256 B | 5 ms | 1 500 |
| Hardware | 20 000 | 8 | 4 | 1 400 / 512 / 48 B | 50 ms | 2 000 |
Sender (unicast), repeated every 300 ms until timeout:
discovery packet (payload DC 01);discoveryResponse with the same session ID ends discovery and selects the channel it came on.Receiver: on a discovery packet with the same session ID, it selects that channel and answers with discoveryResponse.
In AppController’s live receive loop, discovery replies go out only in unicast mode. An optical discovery is answered over acoustic when available, because the receiver’s screen can’t be seen by the sender’s camera.
AdaptiveDecisionEngine.selectBestChannel(testResults):
The result is a ChannelDecision(selectedChannel, score, confidence, reason), which is logged and shown in the dev screens.
lib/core/transport/reliable_transport.dart.
chunks = data split into packetSize pieces (at least one, possibly empty)
packet i (1-based) = PacketCodec.encode(header{type: data, seq: i, totalPackets: N, payloadLength}, chunk)
Every packet carries totalPackets = N, so the receiver knows when it’s done even if packets arrive out of order.
window = [lastAcked + 1, lastAcked + windowSize] ∩ [1, N]
for seq in window: remember as pending (sentAt, retries = 0) and transmit
ACK(seq): if seq > lastAcked: lastAcked = seq; drop pending ≤ seq
NACK(seq): retransmit seq (if retries < maxRetries)
retransmissionRequest(payload = list of u32 LE seqs): retransmit each
timeout: any pending with now − sentAt > ackTimeoutMs → retransmit (retries++)
Send each window’s packets exactly once and never wait for ACKs. Done when lastSent ≥ N. Receivers stay silent, so they don’t drown each other out (especially over sound).
data(seq): if seen → duplicateCount++, re-ACK
else store payload, ACK(seq), NACK every missing seq < max received
complete when all 1…N are present → reassemble in order
createSwitchRequest(newChannel, lastConfirmed) produces a channelSwitchRequest packet with an 8-byte payload [u32 LE newChannelId, u32 LE lastConfirmed] and sequenceNumber = lastConfirmed. createSwitchAck is the same structure, sent on the new channel. resumeFromSequence(lastConfirmed) sets lastAcked and drops pending packets up to it, so nothing already confirmed is resent.
Each iteration of _transferLoop:
lastAcked ≥ N.peerInactivityTimeoutMs → “Timed out waiting for paired device”.i % 20 == 0: _evaluateSwitch().End state: completed on success, otherwise failed (forced).
lib/core/manager/transfer_state_machine.dart. There are ten states:
┌─────────────────────────────── failed ◄── (any state except completed)
│ │
idle ──► discovering ──► testingChannels ──► negotiating ──► transferring ──► completed
▲ │ ▲ │
│ ▼ │ │
│ degraded ─┤ │
│ │ │ │
│ ▼ │ │
│ switchingChannel ─┤ │
│ │ │ │
│ ▼ │ │
│ recovering┘ │
└───────────────────────────────── completed / failed ◄─────────────────────────┘
Valid transitions (exactly as coded):
| From | To |
|---|---|
| idle | discovering, failed |
| discovering | testingChannels, failed |
| testingChannels | negotiating, failed |
| negotiating | transferring, failed |
| transferring | degraded, completed, failed |
| degraded | switchingChannel, recovering, transferring, failed |
| switchingChannel | recovering, transferring, failed |
| recovering | transferring, failed |
| completed | idle |
| failed | idle |
transition(to) throws StateError('Invalid state transition: …') for anything else.forceState(to) bypasses validation. It’s used to mark failure from any state.history with a timestamp, and the dev screens show it.MetricNormalizer)T = clamp(throughput / 25 000, 0, 1) throughput in bit/s
R = clamp(reliability, 0, 1) from a test: packetsReceived / packetsSent
L = clamp(1 − latency / 500, 0, 1) latency in ms
C = clamp(confidence, 0, 1)
S = clamp(stability, 0, 1)
ChannelScorer)score = 0.35·T + 0.25·R + 0.15·L + 0.15·C + 0.10·S (defaultScoringWeights, sum = 1.0)
| Weight | Metric | Why this weight |
|---|---|---|
| 0.35 | Throughput | Transfer time is what users feel most |
| 0.25 | Reliability | Loss costs retransmissions and ACK timeouts |
| 0.15 | Latency | Affects ACK round trips and responsiveness |
| 0.15 | Confidence | Signal-quality evidence from the physical layer |
| 0.10 | Stability | Penalises channels whose quality fluctuates |
setWeights allows other profiles (e.g. a reliability-first weighting) to be plugged in.
scoreTestResult(result): from a 20-packet channel test. Used for initial ranking and for alternatives.scoreMetrics(metrics): from the channel’s live getMetrics(). Used for the current channel during evaluation.| Constant | Value | Source |
|---|---|---|
degradationThreshold |
0.65 | types.dart |
switchHysteresisThreshold |
0.15 | types.dart |
| Evaluation interval | every 20 loop iterations | transfer_manager.dart / orchestrator |
| Test size for alternatives | 20 packets | testPacketCount |
isDegraded(current) = current < 0.65
shouldSwitch(current, alt) = isDegraded(current) AND alt − current ≥ 0.15
Why hysteresis. Without a margin, two channels with similar scores would make the engine flip-flop on every evaluation, and each switch costs a handshake plus a resume. Requiring both “current is bad” and “the alternative is clearly better” prevents that.
_evaluateSwitch procedure:
shouldSwitch, call _performSwitch._performSwitch(from, to):
lastConfirmed = transport.lastAckedSequence.to the active channel; transport.setChannelId(to); resumeFromSequence(lastConfirmed).channelSwitchRequest(to, lastConfirmed). The peer answers with channelSwitchAck in the orchestrator.SwitchEvent(from, to, lastConfirmed, timestamp, reason).lastConfirmed + 1.(The confidence in a simulated test result is confidence × (1 − loss/2).)
| Channel | Throughput | Loss | Latency | T | R | L | C | S | Score |
|---|---|---|---|---|---|---|---|---|---|
| Optical, healthy | 18 000 | 1% | 2 ms | 0.72 | 0.99 | 1.00 | 0.92 | 0.88 | ≈0.87 |
| Acoustic | 9 000 | 4% | 3 ms | 0.36 | 0.96 | 0.99 | 0.76 | 0.72 | ≈0.70 |
| Vibration | 800 | 6% | 120 ms | 0.03 | 0.94 | 0.76 | 0.70 | 0.68 | ≈0.53 |
Optical healthy, step by step:
0.35 × 0.72 = 0.252
0.25 × 0.99 = 0.2475
0.15 × 1.00 = 0.150
0.15 × 0.92 = 0.138
0.10 × 0.88 = 0.088
score = 0.8755 ≈ 0.87
optical-degradesAfter 15 packets, optical collapses to 2 kbps, 30% loss, 500 ms:
T = 2 000 / 25 000 = 0.08 R = 0.70 L = 1 − 500/500 = 0
C = 0.30 S = 0.20
score = 0.35·0.08 + 0.25·0.70 + 0 + 0.15·0.30 + 0.10·0.20
= 0.028 + 0.175 + 0 + 0.045 + 0.020 = 0.268
The scenario’s acoustic alternative (9 kbps, 3% loss, 80 ms):
T = 0.36 R = 0.97 L = 1 − 80/500 = 0.84 C ≈ 0.79 S = 0.88
score = 0.126 + 0.2425 + 0.126 + 0.118 + 0.088 ≈ 0.70
Decision:
isDegraded(0.268) → 0.268 < 0.65 ✓
0.70 − 0.268 = 0.43 → ≥ 0.15 ✓
⇒ switch optical → acoustic, resume from lastConfirmed + 1
The decision is correct. In the current simulator, though, the scenario still ends in failure. The first evaluation only happens at loop iteration 20, and each window on the degraded link takes seconds. By then the cumulative-ACK quirk (§12) has already made the sender believe every packet was delivered. The switch happens with nothing left to resend, and the run times out after 1 500 iterations. Measured runs and details: Simulation Lab.
Current score 0.60 (degraded), best alternative 0.70. The gap of 0.10 is below 0.15, so the engine does not switch and logs “Degraded but hysteresis not met (0.10 < 0.15)”. It stays on the current channel, avoiding a costly switch for a marginal gain.
Current 0.66, alternative 0.95. The current channel isn’t degraded (0.66 ≥ 0.65), so the engine doesn’t switch even though the alternative is much better. By design, the engine switches only to escape a bad channel, not to chase a better one mid-transfer.
| Goal | Change |
|---|---|
| Prefer reliable over fast | Raise the R weight (e.g. 0.40) and lower T (0.20) via ChannelScorer.setWeights |
| Switch sooner | Raise degradationThreshold (e.g. 0.75) |
| Avoid switching unless clearly better | Raise switchHysteresisThreshold (e.g. 0.25) |
| React faster | Evaluate every 10 iterations instead of 20 (costs more test traffic) |
| Channels faster than 25 kbps | Raise maxThroughput in MetricNormalizer, or T saturates at 1 |
Keep the weights summing to 1.0, so scores stay comparable with the 0.65 threshold.
These are documented behaviours of the current code (see also Known Issues and Simulation Lab):
lastAcked = seq, and it drops all pending packets ≤ seq). An out-of-order ACK can therefore mark an unreceived earlier packet as delivered. A later NACK then finds nothing pending to resend, so under heavy loss the sender can report “all acknowledged” while the receiver still has gaps. This is why the optical-degrades and burst-loss simulations fail.TransferManager picks acoustic (fixed score 0.9, “Acoustic is bidirectional”) instead of the scored best.